@yonpark/skillhub-cli 0.2.0 → 0.3.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
@@ -24,55 +24,51 @@ npx @yw9142/skillhub-cli <command>
24
24
 
25
25
  ## Commands
26
26
 
27
- ### Login
27
+ ### Auth
28
28
 
29
29
  ```bash
30
- skillhub login
30
+ skillhub auth login
31
+ skillhub auth status
32
+ skillhub auth status --json
33
+ skillhub auth logout
34
+ skillhub auth logout --yes
35
+ skillhub auth logout --json
31
36
  ```
32
37
 
33
- - Prompts for a GitHub PAT (classic, `gist` scope)
34
- - Verifies token + Gist API access
35
- - Stores token locally via `conf`
38
+ - `auth login`: prompts for a GitHub PAT (classic, `gist` scope), verifies access, and stores it via `conf`
39
+ - `auth status`: shows login state, gist id, last successful sync timestamp, local skill count, and Gist API accessibility
40
+ - `auth logout`: clears stored session keys (`githubToken`, `gistId`, `lastSyncAt`)
36
41
 
37
42
  ### Sync
38
43
 
44
+ `skillhub sync` requires a subcommand.
45
+
39
46
  ```bash
40
- skillhub sync
41
- skillhub sync --strategy union
42
- skillhub sync --strategy latest
43
- skillhub sync --dry-run
44
- skillhub sync --json
47
+ skillhub sync pull
48
+ skillhub sync push
49
+ skillhub sync merge
50
+ skillhub sync auto
45
51
  ```
46
52
 
47
- - `union`: merge local and remote skills, install missing local skills, upload only when changed
48
- - `latest`: compare `remote.updatedAt` and local `lastSyncAt`
49
- - `--dry-run`: compute plan only (no install, no Gist update, no config write)
50
- - `--json`: single JSON output object
51
-
52
- ### Status
53
+ Common options:
53
54
 
54
55
  ```bash
55
- skillhub status
56
- skillhub status --json
56
+ --dry-run # compute plan only (no install/remove/upload/config write)
57
+ --json # single JSON output object
57
58
  ```
58
59
 
59
- Shows:
60
-
61
- - login state
62
- - stored gist id
63
- - last successful sync timestamp
64
- - local skill count (if available)
65
- - remote Gist API accessibility
66
-
67
- ### Logout
60
+ `pull` only:
68
61
 
69
62
  ```bash
70
- skillhub logout
71
- skillhub logout --yes
72
- skillhub logout --json
63
+ --yes # skip deletion confirmation prompt
73
64
  ```
74
65
 
75
- Clears stored session keys: `githubToken`, `gistId`, `lastSyncAt`.
66
+ Mode behavior:
67
+
68
+ - `pull`: mirror remote to local (remote -> local). Installs missing local skills and removes extra local skills.
69
+ - `push`: mirror local to remote (local -> remote). Updates/creates remote Gist payload from local skills.
70
+ - `merge`: union local + remote, installs missing local skills, uploads when remote differs.
71
+ - `auto`: compare `remote.updatedAt` and local `lastSyncAt`; install from remote when remote is newer, otherwise upload local when needed.
76
72
 
77
73
  ## Payload Format
78
74
 
@@ -87,7 +83,7 @@ Clears stored session keys: `githubToken`, `gistId`, `lastSyncAt`.
87
83
  }
88
84
  ```
89
85
 
90
- Backward compatibility for legacy `skills: string[]` is preserved.
86
+ Backward compatibility for legacy `skills: string[]` payloads is preserved.
91
87
 
92
88
  ## Local npm Credentials
93
89
 
@@ -113,9 +109,11 @@ Publish:
113
109
  npm run release
114
110
  ```
115
111
 
116
- CI release workflow requires:
112
+ CI release workflow publishes to GitHub Packages only:
117
113
 
118
- - `NPM_TOKEN` repository secret for npmjs publish of `@yonpark/skillhub-cli`.
119
114
  - GitHub Packages publish uses `GITHUB_TOKEN` by default.
120
115
  - If needed, add `GH_PACKAGES_TOKEN` (PAT with `write:packages`) for GitHub Packages.
121
116
  - GitHub Packages target package is `@yw9142/skillhub-cli`.
117
+
118
+ npmjs publish is manual.
119
+
@@ -1,6 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.runSync = runSync;
3
+ exports.runSyncMerge = runSyncMerge;
4
+ exports.runSyncAuto = runSyncAuto;
5
+ exports.runSyncPush = runSyncPush;
6
+ exports.runSyncPull = runSyncPull;
4
7
  const syncCore_1 = require("../core/syncCore");
5
8
  const config_1 = require("../service/config");
6
9
  const gistService_1 = require("../service/gistService");
@@ -12,11 +15,11 @@ function formatSyncSummary(summary) {
12
15
  ? ` (${summary.failed.length} failed - check logs or JSON output)`
13
16
  : "";
14
17
  const actionLine = summary.dryRun
15
- ? `${prefix}: would upload ${summary.uploaded} change(s), would install ${summary.installPlanned} skill(s)`
16
- : `${prefix}: uploaded ${summary.uploaded} change(s), installed ${summary.installed} skill(s)`;
18
+ ? `${prefix} ${summary.mode}: would upload ${summary.uploaded} change(s), would install ${summary.installPlanned} skill(s), would remove ${summary.removePlanned} skill(s)`
19
+ : `${prefix} ${summary.mode}: uploaded ${summary.uploaded} change(s), installed ${summary.installed} skill(s), removed ${summary.removed} skill(s)`;
17
20
  const details = [
18
21
  actionLine + failurePart,
19
- `strategy=${summary.strategy}`,
22
+ `mode=${summary.mode}`,
20
23
  `gistFound=${summary.gistFound}`,
21
24
  `gistCreated=${summary.gistCreated}`,
22
25
  `remoteNewer=${summary.remoteNewer === null ? "n/a" : String(summary.remoteNewer)}`,
@@ -24,7 +27,49 @@ function formatSyncSummary(summary) {
24
27
  ];
25
28
  return details.join("\n");
26
29
  }
27
- async function resolveRemotePayload(token) {
30
+ function createSummary(params) {
31
+ return {
32
+ ok: params.failed.length === 0,
33
+ mode: params.mode,
34
+ dryRun: params.dryRun,
35
+ gistFound: params.gistFound,
36
+ gistCreated: params.gistCreated,
37
+ remoteNewer: params.remoteNewer,
38
+ uploaded: params.uploaded,
39
+ installPlanned: params.installPlanned,
40
+ installed: params.installed,
41
+ removePlanned: params.removePlanned,
42
+ removed: params.removed,
43
+ failed: params.failed,
44
+ lastSyncAtUpdated: params.lastSyncAtUpdated,
45
+ };
46
+ }
47
+ function finalizeWithFailures(summary, asJson) {
48
+ if (summary.failed.length === 0) {
49
+ return summary;
50
+ }
51
+ if (asJson) {
52
+ process.exitCode = 1;
53
+ return summary;
54
+ }
55
+ throw new Error(`Sync ${summary.mode} completed with ${summary.failed.length} failed operation(s). Check logs above.`);
56
+ }
57
+ async function ensureToken() {
58
+ const token = await config_1.configStore.getToken();
59
+ if (!token) {
60
+ throw new Error("You must login first. Run `skillhub auth login` and try again.");
61
+ }
62
+ return token;
63
+ }
64
+ async function safeGetPayload(octokit, gistId) {
65
+ try {
66
+ return await (0, gistService_1.getSkillhubPayload)(octokit, gistId);
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ }
72
+ async function resolveRemoteState(token) {
28
73
  const octokit = (0, gistService_1.createOctokit)(token);
29
74
  let gistId = await config_1.configStore.getGistId();
30
75
  let remotePayload = null;
@@ -45,52 +90,166 @@ async function resolveRemotePayload(token) {
45
90
  return {
46
91
  octokit,
47
92
  gistId,
48
- remotePayload: remotePayload ?? { skills: [], updatedAt: "" },
93
+ gistFound: Boolean(gistId),
94
+ remotePayload,
49
95
  };
50
96
  }
51
- async function safeGetPayload(octokit, gistId) {
52
- try {
53
- return await (0, gistService_1.getSkillhubPayload)(octokit, gistId);
54
- }
55
- catch {
56
- return null;
57
- }
97
+ function asPlanPayload(payload) {
98
+ return payload ?? { skills: [], updatedAt: "" };
58
99
  }
59
- function createSummaryFromPlan(params) {
100
+ function splitInstallCandidates(candidates) {
101
+ const invalidInstallCandidates = candidates
102
+ .filter((skill) => !(0, skillsService_1.isValidSource)(skill.source))
103
+ .map((skill) => ({
104
+ skill,
105
+ reason: `Invalid source "${skill.source}". Expected owner/repo format.`,
106
+ }));
107
+ const validInstallCandidates = candidates.filter((skill) => (0, skillsService_1.isValidSource)(skill.source));
60
108
  return {
61
- ok: params.failed.length === 0,
62
- strategy: params.strategy,
63
- dryRun: params.dryRun,
64
- gistFound: params.gistFound,
65
- gistCreated: params.gistCreated,
66
- remoteNewer: params.remoteNewer,
67
- uploaded: params.uploaded,
68
- installPlanned: params.installPlanned,
69
- installed: params.installed,
70
- failed: params.failed,
71
- lastSyncAtUpdated: params.lastSyncAtUpdated,
109
+ invalidInstallCandidates,
110
+ validInstallCandidates,
72
111
  };
73
112
  }
74
- async function runSync(options = {}) {
75
- const strategy = (0, syncCore_1.parseStrategy)(options.strategyInput);
113
+ async function confirmPullRemovalsIfNeeded(removeCandidates, options) {
114
+ if (removeCandidates.length === 0 || options.yes === true) {
115
+ return;
116
+ }
117
+ const { default: inquirer } = await import("inquirer");
118
+ const { confirm } = await inquirer.prompt([
119
+ {
120
+ type: "confirm",
121
+ name: "confirm",
122
+ default: false,
123
+ message: `Pull sync will remove ${removeCandidates.length} local skill(s). Continue?`,
124
+ },
125
+ ]);
126
+ if (!confirm) {
127
+ throw new Error("Sync pull cancelled.");
128
+ }
129
+ }
130
+ async function runSyncMerge(options = {}) {
76
131
  const dryRun = options.dryRun === true;
77
132
  const asJson = options.json === true;
78
- const token = await config_1.configStore.getToken();
79
- if (!token) {
80
- throw new Error("You must login first. Run `skillhub login` and try again.");
133
+ const token = await ensureToken();
134
+ const nowIso = new Date().toISOString();
135
+ const localSkills = await (0, skillsService_1.getLocalSkills)();
136
+ const localPayload = {
137
+ skills: localSkills,
138
+ updatedAt: nowIso,
139
+ };
140
+ const { octokit, gistId, gistFound, remotePayload } = await resolveRemoteState(token);
141
+ if (!gistFound) {
142
+ if (dryRun) {
143
+ const summary = createSummary({
144
+ mode: "merge",
145
+ dryRun: true,
146
+ gistFound: false,
147
+ gistCreated: false,
148
+ remoteNewer: null,
149
+ uploaded: 1,
150
+ installPlanned: 0,
151
+ installed: 0,
152
+ removePlanned: 0,
153
+ removed: 0,
154
+ failed: [],
155
+ lastSyncAtUpdated: false,
156
+ });
157
+ (0, output_1.emitOutput)(summary, asJson, formatSyncSummary);
158
+ return summary;
159
+ }
160
+ const created = await (0, gistService_1.createSkillhubGist)(octokit, localPayload);
161
+ if (!created.id) {
162
+ throw new Error("Gist was created, but the ID could not be determined.");
163
+ }
164
+ await config_1.configStore.setGistId(created.id);
165
+ await config_1.configStore.setLastSyncAt(nowIso);
166
+ const summary = createSummary({
167
+ mode: "merge",
168
+ dryRun: false,
169
+ gistFound: false,
170
+ gistCreated: true,
171
+ remoteNewer: null,
172
+ uploaded: 1,
173
+ installPlanned: 0,
174
+ installed: 0,
175
+ removePlanned: 0,
176
+ removed: 0,
177
+ failed: [],
178
+ lastSyncAtUpdated: true,
179
+ });
180
+ (0, output_1.emitOutput)(summary, asJson, formatSyncSummary);
181
+ return summary;
81
182
  }
183
+ const plan = (0, syncCore_1.buildMergePlan)({
184
+ localPayload,
185
+ remotePayload: asPlanPayload(remotePayload),
186
+ nowIso,
187
+ });
188
+ const { invalidInstallCandidates, validInstallCandidates } = splitInstallCandidates(plan.installCandidates);
189
+ if (dryRun) {
190
+ const summary = createSummary({
191
+ mode: "merge",
192
+ dryRun: true,
193
+ gistFound: true,
194
+ gistCreated: false,
195
+ remoteNewer: null,
196
+ uploaded: plan.uploadPayload ? 1 : 0,
197
+ installPlanned: plan.installCandidates.length,
198
+ installed: 0,
199
+ removePlanned: 0,
200
+ removed: 0,
201
+ failed: invalidInstallCandidates,
202
+ lastSyncAtUpdated: false,
203
+ });
204
+ (0, output_1.emitOutput)(summary, asJson, formatSyncSummary);
205
+ return summary;
206
+ }
207
+ const installResult = await (0, skillsService_1.installSkills)(validInstallCandidates, {
208
+ verbose: !asJson,
209
+ });
210
+ const failed = [
211
+ ...invalidInstallCandidates,
212
+ ...installResult.failed,
213
+ ];
214
+ if (plan.uploadPayload) {
215
+ await (0, gistService_1.updateSkillhubGist)(octokit, gistId, plan.uploadPayload);
216
+ }
217
+ const summary = createSummary({
218
+ mode: "merge",
219
+ dryRun: false,
220
+ gistFound: true,
221
+ gistCreated: false,
222
+ remoteNewer: null,
223
+ uploaded: plan.uploadPayload ? 1 : 0,
224
+ installPlanned: plan.installCandidates.length,
225
+ installed: installResult.succeeded.length,
226
+ removePlanned: 0,
227
+ removed: 0,
228
+ failed,
229
+ lastSyncAtUpdated: false,
230
+ });
231
+ if (failed.length === 0) {
232
+ await config_1.configStore.setLastSyncAt(nowIso);
233
+ summary.lastSyncAtUpdated = true;
234
+ }
235
+ (0, output_1.emitOutput)(summary, asJson, formatSyncSummary);
236
+ return finalizeWithFailures(summary, asJson);
237
+ }
238
+ async function runSyncAuto(options = {}) {
239
+ const dryRun = options.dryRun === true;
240
+ const asJson = options.json === true;
241
+ const token = await ensureToken();
82
242
  const nowIso = new Date().toISOString();
83
243
  const localSkills = await (0, skillsService_1.getLocalSkills)();
84
244
  const localPayload = {
85
245
  skills: localSkills,
86
246
  updatedAt: nowIso,
87
247
  };
88
- const { octokit, gistId, remotePayload } = await resolveRemotePayload(token);
89
- const hasRemoteGist = Boolean(gistId);
90
- if (!hasRemoteGist) {
248
+ const { octokit, gistId, gistFound, remotePayload } = await resolveRemoteState(token);
249
+ if (!gistFound) {
91
250
  if (dryRun) {
92
- const summary = createSummaryFromPlan({
93
- strategy,
251
+ const summary = createSummary({
252
+ mode: "auto",
94
253
  dryRun: true,
95
254
  gistFound: false,
96
255
  gistCreated: false,
@@ -98,6 +257,8 @@ async function runSync(options = {}) {
98
257
  uploaded: 1,
99
258
  installPlanned: 0,
100
259
  installed: 0,
260
+ removePlanned: 0,
261
+ removed: 0,
101
262
  failed: [],
102
263
  lastSyncAtUpdated: false,
103
264
  });
@@ -110,8 +271,8 @@ async function runSync(options = {}) {
110
271
  }
111
272
  await config_1.configStore.setGistId(created.id);
112
273
  await config_1.configStore.setLastSyncAt(nowIso);
113
- const summary = createSummaryFromPlan({
114
- strategy,
274
+ const summary = createSummary({
275
+ mode: "auto",
115
276
  dryRun: false,
116
277
  gistFound: false,
117
278
  gistCreated: true,
@@ -119,6 +280,8 @@ async function runSync(options = {}) {
119
280
  uploaded: 1,
120
281
  installPlanned: 0,
121
282
  installed: 0,
283
+ removePlanned: 0,
284
+ removed: 0,
122
285
  failed: [],
123
286
  lastSyncAtUpdated: true,
124
287
  });
@@ -126,30 +289,25 @@ async function runSync(options = {}) {
126
289
  return summary;
127
290
  }
128
291
  const lastSyncAt = await config_1.configStore.getLastSyncAt();
129
- const plan = (0, syncCore_1.buildSyncPlan)({
130
- strategy,
292
+ const plan = (0, syncCore_1.buildAutoPlan)({
131
293
  localPayload,
132
- remotePayload,
294
+ remotePayload: asPlanPayload(remotePayload),
133
295
  lastSyncAt,
134
296
  nowIso,
135
297
  });
136
- const invalidInstallCandidates = plan.installCandidates
137
- .filter((skill) => !(0, skillsService_1.isValidSource)(skill.source))
138
- .map((skill) => ({
139
- skill,
140
- reason: `Invalid source "${skill.source}". Expected owner/repo format.`,
141
- }));
142
- const validInstallCandidates = plan.installCandidates.filter((skill) => (0, skillsService_1.isValidSource)(skill.source));
298
+ const { invalidInstallCandidates, validInstallCandidates } = splitInstallCandidates(plan.installCandidates);
143
299
  if (dryRun) {
144
- const summary = createSummaryFromPlan({
145
- strategy,
300
+ const summary = createSummary({
301
+ mode: "auto",
146
302
  dryRun: true,
147
303
  gistFound: true,
148
304
  gistCreated: false,
149
- remoteNewer: strategy === "latest" ? plan.isRemoteNewer : null,
305
+ remoteNewer: plan.isRemoteNewer,
150
306
  uploaded: plan.uploadPayload ? 1 : 0,
151
307
  installPlanned: plan.installCandidates.length,
152
308
  installed: 0,
309
+ removePlanned: 0,
310
+ removed: 0,
153
311
  failed: invalidInstallCandidates,
154
312
  lastSyncAtUpdated: false,
155
313
  });
@@ -159,19 +317,24 @@ async function runSync(options = {}) {
159
317
  const installResult = await (0, skillsService_1.installSkills)(validInstallCandidates, {
160
318
  verbose: !asJson,
161
319
  });
162
- const failed = [...invalidInstallCandidates, ...installResult.failed];
320
+ const failed = [
321
+ ...invalidInstallCandidates,
322
+ ...installResult.failed,
323
+ ];
163
324
  if (plan.uploadPayload) {
164
325
  await (0, gistService_1.updateSkillhubGist)(octokit, gistId, plan.uploadPayload);
165
326
  }
166
- const summary = createSummaryFromPlan({
167
- strategy,
327
+ const summary = createSummary({
328
+ mode: "auto",
168
329
  dryRun: false,
169
330
  gistFound: true,
170
331
  gistCreated: false,
171
- remoteNewer: strategy === "latest" ? plan.isRemoteNewer : null,
332
+ remoteNewer: plan.isRemoteNewer,
172
333
  uploaded: plan.uploadPayload ? 1 : 0,
173
334
  installPlanned: plan.installCandidates.length,
174
335
  installed: installResult.succeeded.length,
336
+ removePlanned: 0,
337
+ removed: 0,
175
338
  failed,
176
339
  lastSyncAtUpdated: false,
177
340
  });
@@ -180,12 +343,175 @@ async function runSync(options = {}) {
180
343
  summary.lastSyncAtUpdated = true;
181
344
  }
182
345
  (0, output_1.emitOutput)(summary, asJson, formatSyncSummary);
183
- if (failed.length > 0) {
184
- if (asJson) {
185
- process.exitCode = 1;
346
+ return finalizeWithFailures(summary, asJson);
347
+ }
348
+ async function runSyncPush(options = {}) {
349
+ const dryRun = options.dryRun === true;
350
+ const asJson = options.json === true;
351
+ const token = await ensureToken();
352
+ const nowIso = new Date().toISOString();
353
+ const localSkills = await (0, skillsService_1.getLocalSkills)();
354
+ const localPayload = {
355
+ skills: localSkills,
356
+ updatedAt: nowIso,
357
+ };
358
+ const { octokit, gistId, gistFound, remotePayload } = await resolveRemoteState(token);
359
+ if (!gistFound) {
360
+ if (dryRun) {
361
+ const summary = createSummary({
362
+ mode: "push",
363
+ dryRun: true,
364
+ gistFound: false,
365
+ gistCreated: false,
366
+ remoteNewer: null,
367
+ uploaded: 1,
368
+ installPlanned: 0,
369
+ installed: 0,
370
+ removePlanned: 0,
371
+ removed: 0,
372
+ failed: [],
373
+ lastSyncAtUpdated: false,
374
+ });
375
+ (0, output_1.emitOutput)(summary, asJson, formatSyncSummary);
186
376
  return summary;
187
377
  }
188
- throw new Error(`Sync completed with ${failed.length} failed install(s). Check logs above.`);
378
+ const created = await (0, gistService_1.createSkillhubGist)(octokit, localPayload);
379
+ if (!created.id) {
380
+ throw new Error("Gist was created, but the ID could not be determined.");
381
+ }
382
+ await config_1.configStore.setGistId(created.id);
383
+ await config_1.configStore.setLastSyncAt(nowIso);
384
+ const summary = createSummary({
385
+ mode: "push",
386
+ dryRun: false,
387
+ gistFound: false,
388
+ gistCreated: true,
389
+ remoteNewer: null,
390
+ uploaded: 1,
391
+ installPlanned: 0,
392
+ installed: 0,
393
+ removePlanned: 0,
394
+ removed: 0,
395
+ failed: [],
396
+ lastSyncAtUpdated: true,
397
+ });
398
+ (0, output_1.emitOutput)(summary, asJson, formatSyncSummary);
399
+ return summary;
189
400
  }
401
+ const plan = (0, syncCore_1.buildPushPlan)({
402
+ localPayload,
403
+ remotePayload: asPlanPayload(remotePayload),
404
+ nowIso,
405
+ });
406
+ if (dryRun) {
407
+ const summary = createSummary({
408
+ mode: "push",
409
+ dryRun: true,
410
+ gistFound: true,
411
+ gistCreated: false,
412
+ remoteNewer: null,
413
+ uploaded: plan.uploadPayload ? 1 : 0,
414
+ installPlanned: 0,
415
+ installed: 0,
416
+ removePlanned: 0,
417
+ removed: 0,
418
+ failed: [],
419
+ lastSyncAtUpdated: false,
420
+ });
421
+ (0, output_1.emitOutput)(summary, asJson, formatSyncSummary);
422
+ return summary;
423
+ }
424
+ if (plan.uploadPayload) {
425
+ await (0, gistService_1.updateSkillhubGist)(octokit, gistId, plan.uploadPayload);
426
+ }
427
+ await config_1.configStore.setLastSyncAt(nowIso);
428
+ const summary = createSummary({
429
+ mode: "push",
430
+ dryRun: false,
431
+ gistFound: true,
432
+ gistCreated: false,
433
+ remoteNewer: null,
434
+ uploaded: plan.uploadPayload ? 1 : 0,
435
+ installPlanned: 0,
436
+ installed: 0,
437
+ removePlanned: 0,
438
+ removed: 0,
439
+ failed: [],
440
+ lastSyncAtUpdated: true,
441
+ });
442
+ (0, output_1.emitOutput)(summary, asJson, formatSyncSummary);
190
443
  return summary;
191
444
  }
445
+ async function runSyncPull(options = {}) {
446
+ const dryRun = options.dryRun === true;
447
+ const asJson = options.json === true;
448
+ const token = await ensureToken();
449
+ const nowIso = new Date().toISOString();
450
+ const localSkills = await (0, skillsService_1.getLocalSkills)();
451
+ const localPayload = {
452
+ skills: localSkills,
453
+ updatedAt: nowIso,
454
+ };
455
+ const { gistFound, remotePayload } = await resolveRemoteState(token);
456
+ if (!gistFound) {
457
+ throw new Error("Remote SkillHub Gist not found. Run `skillhub sync push` to create it first.");
458
+ }
459
+ if (!remotePayload) {
460
+ throw new Error("Remote SkillHub payload is missing or invalid. Fix the remote `skillhub.json` and retry.");
461
+ }
462
+ const plan = (0, syncCore_1.buildPullPlan)({
463
+ localPayload,
464
+ remotePayload,
465
+ });
466
+ const { invalidInstallCandidates, validInstallCandidates } = splitInstallCandidates(plan.installCandidates);
467
+ if (dryRun) {
468
+ const summary = createSummary({
469
+ mode: "pull",
470
+ dryRun: true,
471
+ gistFound: true,
472
+ gistCreated: false,
473
+ remoteNewer: null,
474
+ uploaded: 0,
475
+ installPlanned: plan.installCandidates.length,
476
+ installed: 0,
477
+ removePlanned: plan.removeCandidates.length,
478
+ removed: 0,
479
+ failed: invalidInstallCandidates,
480
+ lastSyncAtUpdated: false,
481
+ });
482
+ (0, output_1.emitOutput)(summary, asJson, formatSyncSummary);
483
+ return summary;
484
+ }
485
+ await confirmPullRemovalsIfNeeded(plan.removeCandidates, options);
486
+ const installResult = await (0, skillsService_1.installSkills)(validInstallCandidates, {
487
+ verbose: !asJson,
488
+ });
489
+ const removeResult = await (0, skillsService_1.removeSkills)(plan.removeCandidates, {
490
+ verbose: !asJson,
491
+ });
492
+ const failed = [
493
+ ...invalidInstallCandidates,
494
+ ...installResult.failed,
495
+ ...removeResult.failed,
496
+ ];
497
+ const summary = createSummary({
498
+ mode: "pull",
499
+ dryRun: false,
500
+ gistFound: true,
501
+ gistCreated: false,
502
+ remoteNewer: null,
503
+ uploaded: 0,
504
+ installPlanned: plan.installCandidates.length,
505
+ installed: installResult.succeeded.length,
506
+ removePlanned: plan.removeCandidates.length,
507
+ removed: removeResult.succeeded.length,
508
+ failed,
509
+ lastSyncAtUpdated: false,
510
+ });
511
+ if (failed.length === 0) {
512
+ await config_1.configStore.setLastSyncAt(nowIso);
513
+ summary.lastSyncAtUpdated = true;
514
+ }
515
+ (0, output_1.emitOutput)(summary, asJson, formatSyncSummary);
516
+ return finalizeWithFailures(summary, asJson);
517
+ }
@@ -1,11 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.parseStrategy = parseStrategy;
4
3
  exports.parseTimestamp = parseTimestamp;
5
4
  exports.normalizeSkills = normalizeSkills;
6
5
  exports.uniqueSortedSkills = uniqueSortedSkills;
7
6
  exports.areSameSkills = areSameSkills;
8
- exports.buildSyncPlan = buildSyncPlan;
7
+ exports.buildMergePlan = buildMergePlan;
8
+ exports.buildAutoPlan = buildAutoPlan;
9
+ exports.buildPullPlan = buildPullPlan;
10
+ exports.buildPushPlan = buildPushPlan;
9
11
  const DEFAULT_SKILL_SOURCE_REPO = "vercel-labs/agent-skills";
10
12
  const BANNED_SKILL_NAME_SUBSTRINGS = [
11
13
  "No global skills found",
@@ -13,15 +15,6 @@ const BANNED_SKILL_NAME_SUBSTRINGS = [
13
15
  "No project skills found",
14
16
  "Try listing global skills",
15
17
  ];
16
- function parseStrategy(input) {
17
- if (!input) {
18
- return "union";
19
- }
20
- if (input === "union" || input === "latest") {
21
- return input;
22
- }
23
- throw new Error(`Invalid strategy "${input}". Use one of: union, latest.`);
24
- }
25
18
  function parseTimestamp(value) {
26
19
  if (!value) {
27
20
  return null;
@@ -70,34 +63,35 @@ function areSameSkills(left, right) {
70
63
  return leftSorted.every((skill, index) => skill.name === rightSorted[index].name &&
71
64
  skill.source === rightSorted[index].source);
72
65
  }
73
- function buildSyncPlan(params) {
66
+ function buildMergePlan(params) {
74
67
  const localSkills = normalizeSkills(params.localPayload.skills);
75
68
  const remoteSkills = normalizeSkills(params.remotePayload.skills);
76
- if (params.strategy === "union") {
77
- const unionSkills = uniqueSortedSkills([...localSkills, ...remoteSkills]);
78
- const installCandidates = unionSkills.filter((skill) => !localSkills.some((local) => local.name === skill.name && local.source === skill.source));
79
- const uploadPayload = areSameSkills(remoteSkills, unionSkills)
80
- ? null
81
- : {
82
- skills: unionSkills,
83
- updatedAt: params.nowIso,
84
- };
85
- return {
86
- strategy: "union",
87
- localSkills,
88
- remoteSkills,
89
- installCandidates,
90
- uploadPayload,
91
- isRemoteNewer: false,
69
+ const unionSkills = uniqueSortedSkills([...localSkills, ...remoteSkills]);
70
+ const installCandidates = unionSkills.filter((skill) => !localSkills.some((local) => local.name === skill.name && local.source === skill.source));
71
+ const uploadPayload = areSameSkills(remoteSkills, unionSkills)
72
+ ? null
73
+ : {
74
+ skills: unionSkills,
75
+ updatedAt: params.nowIso,
92
76
  };
93
- }
77
+ return {
78
+ mode: "merge",
79
+ localSkills,
80
+ remoteSkills,
81
+ installCandidates,
82
+ uploadPayload,
83
+ };
84
+ }
85
+ function buildAutoPlan(params) {
86
+ const localSkills = normalizeSkills(params.localPayload.skills);
87
+ const remoteSkills = normalizeSkills(params.remotePayload.skills);
94
88
  const lastSyncTime = parseTimestamp(params.lastSyncAt) ?? 0;
95
89
  const remoteTime = parseTimestamp(params.remotePayload.updatedAt);
96
90
  const isRemoteNewer = remoteTime !== null && remoteTime > lastSyncTime;
97
91
  if (isRemoteNewer) {
98
92
  const installCandidates = remoteSkills.filter((skill) => !localSkills.some((local) => local.name === skill.name && local.source === skill.source));
99
93
  return {
100
- strategy: "latest",
94
+ mode: "auto",
101
95
  localSkills,
102
96
  remoteSkills,
103
97
  installCandidates,
@@ -112,7 +106,7 @@ function buildSyncPlan(params) {
112
106
  updatedAt: params.nowIso,
113
107
  };
114
108
  return {
115
- strategy: "latest",
109
+ mode: "auto",
116
110
  localSkills,
117
111
  remoteSkills,
118
112
  installCandidates: [],
@@ -120,3 +114,32 @@ function buildSyncPlan(params) {
120
114
  isRemoteNewer,
121
115
  };
122
116
  }
117
+ function buildPullPlan(params) {
118
+ const localSkills = normalizeSkills(params.localPayload.skills);
119
+ const remoteSkills = normalizeSkills(params.remotePayload.skills);
120
+ const installCandidates = remoteSkills.filter((remote) => !localSkills.some((local) => local.name === remote.name && local.source === remote.source));
121
+ const removeCandidates = localSkills.filter((local) => !remoteSkills.some((remote) => remote.name === local.name && remote.source === local.source));
122
+ return {
123
+ mode: "pull",
124
+ localSkills,
125
+ remoteSkills,
126
+ installCandidates,
127
+ removeCandidates,
128
+ };
129
+ }
130
+ function buildPushPlan(params) {
131
+ const localSkills = normalizeSkills(params.localPayload.skills);
132
+ const remoteSkills = normalizeSkills(params.remotePayload.skills);
133
+ const uploadPayload = areSameSkills(localSkills, remoteSkills)
134
+ ? null
135
+ : {
136
+ skills: localSkills,
137
+ updatedAt: params.nowIso,
138
+ };
139
+ return {
140
+ mode: "push",
141
+ localSkills,
142
+ remoteSkills,
143
+ uploadPayload,
144
+ };
145
+ }
package/dist/index.js CHANGED
@@ -17,80 +17,100 @@ function getPackageVersion() {
17
17
  function errorMessage(error) {
18
18
  return error instanceof Error ? error.message : String(error);
19
19
  }
20
+ function withJsonErrorHandling(action) {
21
+ return async (options) => {
22
+ try {
23
+ await action(options);
24
+ }
25
+ catch (error) {
26
+ if (options.json) {
27
+ console.log(JSON.stringify({
28
+ ok: false,
29
+ error: errorMessage(error),
30
+ }, null, 2));
31
+ process.exitCode = 1;
32
+ return;
33
+ }
34
+ throw error;
35
+ }
36
+ };
37
+ }
20
38
  const program = new commander_1.Command();
21
39
  program.name("skillhub").description("SkillHub CLI").version(getPackageVersion());
22
- program
40
+ const authCommand = program.command("auth").description("Authentication commands");
41
+ authCommand
23
42
  .command("login")
24
- .description("Login: register your GitHub PAT with gist access")
43
+ .description("Register your GitHub PAT with gist access")
25
44
  .action(async () => {
26
45
  await (0, login_1.runLogin)();
27
46
  });
28
- program
29
- .command("sync")
30
- .description("Sync: reconcile local skills with remote Gist backup")
31
- .option("-s, --strategy <strategy>", "merge strategy (union|latest)", "union")
32
- .option("--dry-run", "show planned changes without applying them", false)
33
- .option("--json", "print output as JSON", false)
34
- .action(async (options) => {
35
- try {
36
- await (0, sync_1.runSync)({
37
- strategyInput: options.strategy,
38
- dryRun: options.dryRun,
39
- json: options.json,
40
- });
41
- }
42
- catch (error) {
43
- if (options.json) {
44
- console.log(JSON.stringify({
45
- ok: false,
46
- error: errorMessage(error),
47
- }, null, 2));
48
- process.exitCode = 1;
49
- return;
50
- }
51
- throw error;
52
- }
53
- });
54
- program
47
+ authCommand
55
48
  .command("status")
56
49
  .description("Show local auth/sync status")
57
50
  .option("--json", "print output as JSON", false)
58
- .action(async (options) => {
59
- try {
60
- await (0, status_1.runStatus)({ json: options.json });
61
- }
62
- catch (error) {
63
- if (options.json) {
64
- console.log(JSON.stringify({
65
- ok: false,
66
- error: errorMessage(error),
67
- }, null, 2));
68
- process.exitCode = 1;
69
- return;
70
- }
71
- throw error;
72
- }
73
- });
74
- program
51
+ .action(withJsonErrorHandling(async (options) => {
52
+ await (0, status_1.runStatus)({ json: options.json });
53
+ }));
54
+ authCommand
75
55
  .command("logout")
76
56
  .description("Clear stored session data (token, gist id, last sync)")
77
57
  .option("--yes", "skip confirmation prompt", false)
78
58
  .option("--json", "print output as JSON", false)
79
- .action(async (options) => {
80
- try {
81
- await (0, logout_1.runLogout)({ yes: options.yes, json: options.json });
82
- }
83
- catch (error) {
84
- if (options.json) {
85
- console.log(JSON.stringify({
86
- ok: false,
87
- error: errorMessage(error),
88
- }, null, 2));
89
- process.exitCode = 1;
90
- return;
91
- }
92
- throw error;
93
- }
59
+ .action(withJsonErrorHandling(async (options) => {
60
+ await (0, logout_1.runLogout)({ yes: options.yes, json: options.json });
61
+ }));
62
+ const syncCommand = program
63
+ .command("sync")
64
+ .description("Sync local skills and remote Gist backup");
65
+ syncCommand
66
+ .command("pull")
67
+ .description("Mirror remote skills into local skills (remote -> local)")
68
+ .option("--dry-run", "show planned changes without applying them", false)
69
+ .option("--yes", "skip deletion confirmation prompt", false)
70
+ .option("--json", "print output as JSON", false)
71
+ .action(withJsonErrorHandling(async (options) => {
72
+ await (0, sync_1.runSyncPull)({
73
+ dryRun: options.dryRun,
74
+ yes: options.yes,
75
+ json: options.json,
76
+ });
77
+ }));
78
+ syncCommand
79
+ .command("push")
80
+ .description("Mirror local skills into remote backup (local -> remote)")
81
+ .option("--dry-run", "show planned changes without applying them", false)
82
+ .option("--json", "print output as JSON", false)
83
+ .action(withJsonErrorHandling(async (options) => {
84
+ await (0, sync_1.runSyncPush)({
85
+ dryRun: options.dryRun,
86
+ json: options.json,
87
+ });
88
+ }));
89
+ syncCommand
90
+ .command("merge")
91
+ .description("Merge local and remote skills (union behavior)")
92
+ .option("--dry-run", "show planned changes without applying them", false)
93
+ .option("--json", "print output as JSON", false)
94
+ .action(withJsonErrorHandling(async (options) => {
95
+ await (0, sync_1.runSyncMerge)({
96
+ dryRun: options.dryRun,
97
+ json: options.json,
98
+ });
99
+ }));
100
+ syncCommand
101
+ .command("auto")
102
+ .description("Sync using remote.updatedAt and lastSyncAt comparison")
103
+ .option("--dry-run", "show planned changes without applying them", false)
104
+ .option("--json", "print output as JSON", false)
105
+ .action(withJsonErrorHandling(async (options) => {
106
+ await (0, sync_1.runSyncAuto)({
107
+ dryRun: options.dryRun,
108
+ json: options.json,
109
+ });
110
+ }));
111
+ syncCommand.action(() => {
112
+ syncCommand.outputHelp();
113
+ throw new Error("Missing sync mode. Use one of: pull, push, merge, auto.");
94
114
  });
95
115
  program.parseAsync(process.argv).catch((error) => {
96
116
  console.error(`Error: ${errorMessage(error)}`);
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.isValidSource = isValidSource;
7
7
  exports.getLocalSkills = getLocalSkills;
8
8
  exports.installSkills = installSkills;
9
+ exports.removeSkills = removeSkills;
9
10
  const node_child_process_1 = require("node:child_process");
10
11
  const promises_1 = __importDefault(require("node:fs/promises"));
11
12
  const node_os_1 = __importDefault(require("node:os"));
@@ -14,7 +15,7 @@ const node_util_1 = require("node:util");
14
15
  const syncCore_1 = require("../core/syncCore");
15
16
  const retry_1 = require("../utils/retry");
16
17
  const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
17
- const SKILLS_LOCK_FILENAME = "skills-lock.json";
18
+ const SKILLS_LOCK_FILENAMES = ["skills-lock.json", ".skill-lock.json"];
18
19
  const DEFAULT_SKILL_SOURCE_REPO = "vercel-labs/agent-skills";
19
20
  const NPX_COMMAND = process.platform === "win32" ? "npx.cmd" : "npx";
20
21
  const SOURCE_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
@@ -35,26 +36,31 @@ async function runSkillsCommand(args, label) {
35
36
  function isValidSource(source) {
36
37
  return SOURCE_PATTERN.test(source);
37
38
  }
38
- function getCandidateSkillsLockPaths() {
39
- const cwdPath = node_path_1.default.resolve(process.cwd(), SKILLS_LOCK_FILENAME);
40
- const homePath = node_path_1.default.resolve(node_os_1.default.homedir(), SKILLS_LOCK_FILENAME);
39
+ function getCandidateSkillsLockPaths(filename) {
40
+ const cwdPath = node_path_1.default.resolve(process.cwd(), filename);
41
+ const homePath = node_path_1.default.resolve(node_os_1.default.homedir(), filename);
41
42
  const homeConfigPaths = [
42
- node_path_1.default.resolve(node_os_1.default.homedir(), ".config", "skills", SKILLS_LOCK_FILENAME),
43
- node_path_1.default.resolve(node_os_1.default.homedir(), ".config", "skillhub", SKILLS_LOCK_FILENAME),
44
- node_path_1.default.resolve(node_os_1.default.homedir(), ".skills", SKILLS_LOCK_FILENAME),
43
+ node_path_1.default.resolve(node_os_1.default.homedir(), ".config", "skills", filename),
44
+ node_path_1.default.resolve(node_os_1.default.homedir(), ".config", "skillhub", filename),
45
+ node_path_1.default.resolve(node_os_1.default.homedir(), ".skills", filename),
46
+ node_path_1.default.resolve(node_os_1.default.homedir(), ".agents", filename),
45
47
  ];
46
48
  const winAppData = process.env.APPDATA;
47
49
  const winLocalAppData = process.env.LOCALAPPDATA;
48
50
  const windowsConfigPaths = [
49
51
  ...(winAppData
50
- ? [node_path_1.default.resolve(winAppData, "skills", SKILLS_LOCK_FILENAME)]
52
+ ? [node_path_1.default.resolve(winAppData, "skills", filename)]
51
53
  : []),
52
54
  ...(winLocalAppData
53
- ? [node_path_1.default.resolve(winLocalAppData, "skills", SKILLS_LOCK_FILENAME)]
55
+ ? [node_path_1.default.resolve(winLocalAppData, "skills", filename)]
54
56
  : []),
55
57
  ];
56
58
  return [cwdPath, homePath, ...homeConfigPaths, ...windowsConfigPaths];
57
59
  }
60
+ function getAllCandidateSkillsLockPaths() {
61
+ const paths = SKILLS_LOCK_FILENAMES.flatMap((filename) => getCandidateSkillsLockPaths(filename));
62
+ return [...new Set(paths)];
63
+ }
58
64
  function parseSkillsLock(raw) {
59
65
  const parsed = JSON.parse(raw);
60
66
  const extractSkills = (items) => {
@@ -82,6 +88,21 @@ function parseSkillsLock(raw) {
82
88
  if (Array.isArray(parsed?.skills)) {
83
89
  return extractSkills(parsed.skills);
84
90
  }
91
+ if (parsed?.skills && typeof parsed.skills === "object") {
92
+ const objectSkills = parsed.skills;
93
+ return Object.entries(objectSkills).map(([name, metadata]) => {
94
+ if (typeof metadata === "object" && metadata !== null) {
95
+ const objectItem = metadata;
96
+ const source = typeof objectItem.source === "string"
97
+ ? objectItem.source
98
+ : typeof objectItem.repo === "string"
99
+ ? objectItem.repo
100
+ : DEFAULT_SKILL_SOURCE_REPO;
101
+ return { name, source };
102
+ }
103
+ return { name, source: DEFAULT_SKILL_SOURCE_REPO };
104
+ });
105
+ }
85
106
  if (Array.isArray(parsed)) {
86
107
  return extractSkills(parsed);
87
108
  }
@@ -127,6 +148,45 @@ async function tryReadSkillsLock(lockPath) {
127
148
  return null;
128
149
  }
129
150
  }
151
+ async function readFirstAvailableSkillsLock() {
152
+ const candidatePaths = getAllCandidateSkillsLockPaths();
153
+ for (const lockPath of candidatePaths) {
154
+ const parsed = await tryReadSkillsLock(lockPath);
155
+ if (parsed && parsed.length > 0) {
156
+ return {
157
+ skills: (0, syncCore_1.normalizeSkills)(parsed),
158
+ candidatePaths,
159
+ };
160
+ }
161
+ }
162
+ return {
163
+ skills: [],
164
+ candidatePaths,
165
+ };
166
+ }
167
+ function hydrateSourcesFromLock(listSkills, lockSkills) {
168
+ const lockByName = new Map();
169
+ for (const skill of lockSkills) {
170
+ const existing = lockByName.get(skill.name);
171
+ if (existing) {
172
+ existing.push(skill);
173
+ continue;
174
+ }
175
+ lockByName.set(skill.name, [skill]);
176
+ }
177
+ const hydrated = listSkills.map((skill) => {
178
+ const matches = lockByName.get(skill.name);
179
+ if (!matches || matches.length === 0) {
180
+ return skill;
181
+ }
182
+ const preferred = matches.find((item) => isValidSource(item.source)) ?? matches[0];
183
+ return {
184
+ name: skill.name,
185
+ source: preferred.source,
186
+ };
187
+ });
188
+ return (0, syncCore_1.normalizeSkills)(hydrated);
189
+ }
130
190
  async function getLocalSkills() {
131
191
  const listResult = await runSkillsCommand(["list", "-g"], "skills list -g");
132
192
  const listOutput = stringifyCommandResult(listResult);
@@ -135,32 +195,23 @@ async function getLocalSkills() {
135
195
  return [];
136
196
  }
137
197
  const fromList = parseSkillsListOutput(listOutput);
198
+ const fromLock = await readFirstAvailableSkillsLock();
138
199
  if (fromList.length > 0) {
139
- return fromList;
140
- }
141
- const lockResult = await runSkillsCommand(["generate-lock"], "skills generate-lock");
142
- const lockOutput = stringifyCommandResult(lockResult);
143
- const candidatePaths = getCandidateSkillsLockPaths();
144
- for (const lockPath of candidatePaths) {
145
- const parsed = await tryReadSkillsLock(lockPath);
146
- if (parsed) {
147
- return (0, syncCore_1.normalizeSkills)(parsed);
200
+ if (fromLock.skills.length > 0) {
201
+ return hydrateSourcesFromLock(fromList, fromLock.skills);
148
202
  }
203
+ return fromList;
149
204
  }
150
- if (lockOutput.includes("No installed skills found") ||
151
- listOutput.includes("No project skills found")) {
152
- return [];
205
+ if (fromLock.skills.length > 0) {
206
+ return fromLock.skills;
153
207
  }
154
208
  throw new Error([
155
209
  "Unable to construct local skills list.",
156
210
  "- skills list -g output:",
157
211
  listOutput,
158
212
  "",
159
- `- Searched ${SKILLS_LOCK_FILENAME} paths:`,
160
- ...candidatePaths.map((p) => ` - ${p}`),
161
- "",
162
- "- npx skills generate-lock output:",
163
- lockOutput,
213
+ "- Searched lock paths:",
214
+ ...fromLock.candidatePaths.map((p) => ` - ${p}`),
164
215
  ].join("\n"));
165
216
  }
166
217
  async function installSkills(skills, options = {}) {
@@ -195,3 +246,32 @@ async function installSkills(skills, options = {}) {
195
246
  }
196
247
  return { succeeded, failed };
197
248
  }
249
+ async function removeSkills(skills, options = {}) {
250
+ const succeeded = [];
251
+ const failed = [];
252
+ const seen = new Set();
253
+ for (const skill of skills) {
254
+ const key = `${skill.source}:${skill.name}`;
255
+ if (seen.has(key)) {
256
+ continue;
257
+ }
258
+ seen.add(key);
259
+ try {
260
+ const result = await runSkillsCommand(["remove", "--skill", skill.name, "--global", "--yes"], `skills remove --skill ${skill.name}`);
261
+ const output = stringifyCommandResult(result);
262
+ if (options.verbose && output) {
263
+ console.log(output);
264
+ }
265
+ succeeded.push(skill);
266
+ }
267
+ catch (error) {
268
+ const reason = error instanceof Error ? error.message : String(error);
269
+ failed.push({ skill, reason });
270
+ if (options.verbose) {
271
+ console.warn(`Skill remove failed: ${skill.name} (from ${skill.source})`);
272
+ console.warn(` - ${reason}`);
273
+ }
274
+ }
275
+ }
276
+ return { succeeded, failed };
277
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yonpark/skillhub-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "SkillHub CLI - sync skills with GitHub Gist",
5
5
  "bin": {
6
6
  "skillhub": "bin/skillhub.js"