@yonpark/skillhub-cli 0.1.3 → 0.3.0

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.
@@ -1,31 +1,75 @@
1
1
  "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
2
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.runSync = runSync;
7
- const node_child_process_1 = require("node:child_process");
8
- const node_util_1 = require("node:util");
9
- const promises_1 = __importDefault(require("node:fs/promises"));
10
- const node_path_1 = __importDefault(require("node:path"));
11
- const node_os_1 = __importDefault(require("node:os"));
3
+ exports.runSyncMerge = runSyncMerge;
4
+ exports.runSyncAuto = runSyncAuto;
5
+ exports.runSyncPush = runSyncPush;
6
+ exports.runSyncPull = runSyncPull;
7
+ const syncCore_1 = require("../core/syncCore");
12
8
  const config_1 = require("../service/config");
13
9
  const gistService_1 = require("../service/gistService");
14
- const execAsync = (0, node_util_1.promisify)(node_child_process_1.exec);
15
- const SKILLS_LOCK_FILENAME = "skills-lock.json";
16
- const DEFAULT_STRATEGY = "union";
17
- const DEFAULT_SKILL_SOURCE_REPO = "vercel-labs/agent-skills";
18
- async function runSync(strategyInput) {
19
- const strategy = parseStrategy(strategyInput);
10
+ const skillsService_1 = require("../service/skillsService");
11
+ const output_1 = require("../utils/output");
12
+ function formatSyncSummary(summary) {
13
+ const prefix = summary.dryRun ? "Dry-run" : "Sync";
14
+ const failurePart = summary.failed.length > 0
15
+ ? ` (${summary.failed.length} failed - check logs or JSON output)`
16
+ : "";
17
+ const actionLine = summary.dryRun
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)`;
20
+ const details = [
21
+ actionLine + failurePart,
22
+ `mode=${summary.mode}`,
23
+ `gistFound=${summary.gistFound}`,
24
+ `gistCreated=${summary.gistCreated}`,
25
+ `remoteNewer=${summary.remoteNewer === null ? "n/a" : String(summary.remoteNewer)}`,
26
+ `lastSyncAtUpdated=${summary.lastSyncAtUpdated}`,
27
+ ];
28
+ return details.join("\n");
29
+ }
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() {
20
58
  const token = await config_1.configStore.getToken();
21
59
  if (!token) {
22
- throw new Error("You must login first. Run `skillhub login` and try again.");
60
+ throw new Error("You must login first. Run `skillhub auth login` and try again.");
23
61
  }
24
- const localSkills = await getLocalSkills();
25
- const localPayload = {
26
- skills: uniqueSortedSkills(localSkills),
27
- updatedAt: new Date().toISOString(),
28
- };
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) {
29
73
  const octokit = (0, gistService_1.createOctokit)(token);
30
74
  let gistId = await config_1.configStore.getGistId();
31
75
  let remotePayload = null;
@@ -38,292 +82,436 @@ async function runSync(strategyInput) {
38
82
  if (!gistId) {
39
83
  const found = await (0, gistService_1.findSkillhubGist)(octokit);
40
84
  if (found?.id) {
41
- const foundId = found.id;
42
- gistId = foundId;
43
- await config_1.configStore.setGistId(foundId);
44
- remotePayload = await safeGetPayload(octokit, foundId);
85
+ gistId = found.id;
86
+ await config_1.configStore.setGistId(found.id);
87
+ remotePayload = await safeGetPayload(octokit, found.id);
45
88
  }
46
89
  }
47
- if (!gistId) {
48
- const created = await (0, gistService_1.createSkillhubGist)(octokit, localPayload);
49
- if (!created.id) {
50
- throw new Error("Gist was created, but the ID could not be determined.");
51
- }
52
- await config_1.configStore.setGistId(created.id);
53
- console.log("No existing SkillHub Gist found. A new one has been created.");
54
- console.log(`Uploaded 1 change, installed 0 skills`);
55
- return;
56
- }
57
- const resolvedRemote = remotePayload
58
- ? {
59
- ...remotePayload,
60
- skills: normalizeSkills(remotePayload.skills ?? []),
61
- }
62
- : { skills: [], updatedAt: "" };
63
- if (strategy === "latest") {
64
- await applyLatestStrategy({
65
- octokit,
66
- gistId,
67
- local: localPayload,
68
- remote: resolvedRemote,
69
- });
70
- return;
71
- }
72
- await applyUnionStrategy({
90
+ return {
73
91
  octokit,
74
92
  gistId,
75
- local: localPayload,
76
- remote: resolvedRemote,
77
- });
93
+ gistFound: Boolean(gistId),
94
+ remotePayload,
95
+ };
78
96
  }
79
- function parseStrategy(input) {
80
- if (input === "latest" || input === "union") {
81
- return input;
82
- }
83
- return DEFAULT_STRATEGY;
97
+ function asPlanPayload(payload) {
98
+ return payload ?? { skills: [], updatedAt: "" };
84
99
  }
85
- async function getLocalSkills() {
86
- // 1) Primary source: `skills list -g` output
87
- // - generate-lock can be flaky (e.g. "already in lock file" without writing),
88
- // so we prefer parsing the list output when possible.
89
- const listResult = await execAsync("npx skills list -g");
90
- const listOutput = `${listResult.stdout ?? ""}\n${listResult.stderr ?? ""}`.trim();
91
- // When there are no global skills, skills CLI prints:
92
- // "No global skills found.\nTry listing project skills without -g"
93
- // In that case we treat it as an empty list and avoid polluting Gist.
94
- if (listOutput.includes("No global skills found") ||
95
- listOutput.includes("Try listing project skills without -g")) {
96
- return [];
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));
108
+ return {
109
+ invalidInstallCandidates,
110
+ validInstallCandidates,
111
+ };
112
+ }
113
+ async function confirmPullRemovalsIfNeeded(removeCandidates, options) {
114
+ if (removeCandidates.length === 0 || options.yes === true) {
115
+ return;
97
116
  }
98
- const fromList = parseSkillsListOutput(listOutput);
99
- if (fromList.length > 0) {
100
- return fromList;
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.");
101
128
  }
102
- // 2) Fallback: generate-lock + search for a skills-lock.json file
103
- const { stdout, stderr } = await execAsync("npx skills generate-lock");
104
- const output = `${stdout ?? ""}\n${stderr ?? ""}`.trim();
105
- const candidatePaths = getCandidateSkillsLockPaths();
106
- for (const lockPath of candidatePaths) {
107
- const parsed = await tryReadSkillsLock(lockPath);
108
- if (parsed) {
109
- return parsed;
129
+ }
130
+ async function runSyncMerge(options = {}) {
131
+ const dryRun = options.dryRun === true;
132
+ const asJson = options.json === true;
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.");
110
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;
111
182
  }
112
- if (output.includes("No installed skills found") || listOutput.includes("No project skills found")) {
113
- return [];
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;
114
206
  }
115
- throw new Error([
116
- `Unable to construct local skills list.`,
117
- `- skills list -g output:`,
118
- listOutput,
119
- ``,
120
- `- Searched ${SKILLS_LOCK_FILENAME} paths:`,
121
- ...candidatePaths.map((p) => ` - ${p}`),
122
- ``,
123
- `- npx skills generate-lock output:`,
124
- output,
125
- ].join("\n"));
126
- }
127
- function getCandidateSkillsLockPaths() {
128
- const cwdPath = node_path_1.default.resolve(process.cwd(), SKILLS_LOCK_FILENAME);
129
- const homePath = node_path_1.default.resolve(node_os_1.default.homedir(), SKILLS_LOCK_FILENAME);
130
- // Cross-platform candidates when we don't know exactly where the tool writes
131
- const homeConfigPaths = [
132
- node_path_1.default.resolve(node_os_1.default.homedir(), ".config", "skills", SKILLS_LOCK_FILENAME),
133
- node_path_1.default.resolve(node_os_1.default.homedir(), ".config", "skillhub", SKILLS_LOCK_FILENAME),
134
- node_path_1.default.resolve(node_os_1.default.homedir(), ".skills", SKILLS_LOCK_FILENAME),
135
- ];
136
- // Windows-specific candidates
137
- const winAppData = process.env.APPDATA;
138
- const winLocalAppData = process.env.LOCALAPPDATA;
139
- const windowsConfigPaths = [
140
- ...(winAppData
141
- ? [node_path_1.default.resolve(winAppData, "skills", SKILLS_LOCK_FILENAME)]
142
- : []),
143
- ...(winLocalAppData
144
- ? [node_path_1.default.resolve(winLocalAppData, "skills", SKILLS_LOCK_FILENAME)]
145
- : []),
207
+ const installResult = await (0, skillsService_1.installSkills)(validInstallCandidates, {
208
+ verbose: !asJson,
209
+ });
210
+ const failed = [
211
+ ...invalidInstallCandidates,
212
+ ...installResult.failed,
146
213
  ];
147
- return [cwdPath, homePath, ...homeConfigPaths, ...windowsConfigPaths];
148
- }
149
- async function tryReadSkillsLock(lockPath) {
150
- try {
151
- const raw = await promises_1.default.readFile(lockPath, "utf-8");
152
- return parseSkillsLock(raw);
214
+ if (plan.uploadPayload) {
215
+ await (0, gistService_1.updateSkillhubGist)(octokit, gistId, plan.uploadPayload);
153
216
  }
154
- catch {
155
- return null;
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;
156
234
  }
235
+ (0, output_1.emitOutput)(summary, asJson, formatSyncSummary);
236
+ return finalizeWithFailures(summary, asJson);
157
237
  }
158
- function parseSkillsLock(raw) {
159
- const parsed = JSON.parse(raw);
160
- // skills-lock.json에서 source 정보 추출 시도
161
- const extractSkills = (items) => {
162
- return items.map((item) => {
163
- if (typeof item === "string") {
164
- return { name: item, source: DEFAULT_SKILL_SOURCE_REPO };
165
- }
166
- if (typeof item === "object" && item !== null) {
167
- const name = item.name || item.skill || String(item);
168
- const source = item.source || item.repo || DEFAULT_SKILL_SOURCE_REPO;
169
- return { name: String(name), source: String(source) };
170
- }
171
- return { name: String(item), source: DEFAULT_SKILL_SOURCE_REPO };
172
- });
238
+ async function runSyncAuto(options = {}) {
239
+ const dryRun = options.dryRun === true;
240
+ const asJson = options.json === true;
241
+ const token = await ensureToken();
242
+ const nowIso = new Date().toISOString();
243
+ const localSkills = await (0, skillsService_1.getLocalSkills)();
244
+ const localPayload = {
245
+ skills: localSkills,
246
+ updatedAt: nowIso,
173
247
  };
174
- const fromSkills = parsed?.skills;
175
- if (Array.isArray(fromSkills)) {
176
- return extractSkills(fromSkills);
177
- }
178
- if (Array.isArray(parsed)) {
179
- return extractSkills(parsed);
180
- }
181
- const fromInstalled = parsed?.installedSkills;
182
- if (Array.isArray(fromInstalled)) {
183
- return extractSkills(fromInstalled);
248
+ const { octokit, gistId, gistFound, remotePayload } = await resolveRemoteState(token);
249
+ if (!gistFound) {
250
+ if (dryRun) {
251
+ const summary = createSummary({
252
+ mode: "auto",
253
+ dryRun: true,
254
+ gistFound: false,
255
+ gistCreated: false,
256
+ remoteNewer: null,
257
+ uploaded: 1,
258
+ installPlanned: 0,
259
+ installed: 0,
260
+ removePlanned: 0,
261
+ removed: 0,
262
+ failed: [],
263
+ lastSyncAtUpdated: false,
264
+ });
265
+ (0, output_1.emitOutput)(summary, asJson, formatSyncSummary);
266
+ return summary;
267
+ }
268
+ const created = await (0, gistService_1.createSkillhubGist)(octokit, localPayload);
269
+ if (!created.id) {
270
+ throw new Error("Gist was created, but the ID could not be determined.");
271
+ }
272
+ await config_1.configStore.setGistId(created.id);
273
+ await config_1.configStore.setLastSyncAt(nowIso);
274
+ const summary = createSummary({
275
+ mode: "auto",
276
+ dryRun: false,
277
+ gistFound: false,
278
+ gistCreated: true,
279
+ remoteNewer: null,
280
+ uploaded: 1,
281
+ installPlanned: 0,
282
+ installed: 0,
283
+ removePlanned: 0,
284
+ removed: 0,
285
+ failed: [],
286
+ lastSyncAtUpdated: true,
287
+ });
288
+ (0, output_1.emitOutput)(summary, asJson, formatSyncSummary);
289
+ return summary;
184
290
  }
185
- return [];
186
- }
187
- function parseSkillsListOutput(output) {
188
- const cleaned = output.replace(/\x1b\[[0-9;]*m/g, "");
189
- const lines = cleaned.split(/\r?\n/).map((line) => line.trim());
190
- const skills = [];
191
- for (const line of lines) {
192
- if (!line)
193
- continue;
194
- if (line === "Global Skills")
195
- continue;
196
- if (line.startsWith("Agents:"))
197
- continue;
198
- if (line.startsWith("No project skills found"))
199
- continue;
200
- if (line.startsWith("Try listing global skills"))
201
- continue;
202
- if (line.startsWith("No global skills found"))
203
- continue;
204
- if (line.startsWith("Try listing project skills without -g"))
205
- continue;
206
- // Example: "find-skills ~\\.agents\\skills\\find-skills"
207
- // We only take the first token as the skill name
208
- const [name] = line.split(/\s+/);
209
- if (!name)
210
- continue;
211
- // Skip obvious path-like tokens as a safety net
212
- if (name.includes("\\") || name.includes("/") || name.includes("~"))
213
- continue;
214
- // skills list 출력에는 source 정보가 없으므로 기본값 사용
215
- skills.push({ name, source: DEFAULT_SKILL_SOURCE_REPO });
291
+ const lastSyncAt = await config_1.configStore.getLastSyncAt();
292
+ const plan = (0, syncCore_1.buildAutoPlan)({
293
+ localPayload,
294
+ remotePayload: asPlanPayload(remotePayload),
295
+ lastSyncAt,
296
+ nowIso,
297
+ });
298
+ const { invalidInstallCandidates, validInstallCandidates } = splitInstallCandidates(plan.installCandidates);
299
+ if (dryRun) {
300
+ const summary = createSummary({
301
+ mode: "auto",
302
+ dryRun: true,
303
+ gistFound: true,
304
+ gistCreated: false,
305
+ remoteNewer: plan.isRemoteNewer,
306
+ uploaded: plan.uploadPayload ? 1 : 0,
307
+ installPlanned: plan.installCandidates.length,
308
+ installed: 0,
309
+ removePlanned: 0,
310
+ removed: 0,
311
+ failed: invalidInstallCandidates,
312
+ lastSyncAtUpdated: false,
313
+ });
314
+ (0, output_1.emitOutput)(summary, asJson, formatSyncSummary);
315
+ return summary;
216
316
  }
217
- return uniqueSortedSkills(skills);
218
- }
219
- function normalizeSkills(skills) {
220
- const bannedSubstrings = [
221
- "No global skills found",
222
- "Try listing project skills without -g",
223
- "No project skills found",
224
- "Try listing global skills",
317
+ const installResult = await (0, skillsService_1.installSkills)(validInstallCandidates, {
318
+ verbose: !asJson,
319
+ });
320
+ const failed = [
321
+ ...invalidInstallCandidates,
322
+ ...installResult.failed,
225
323
  ];
226
- const normalized = skills
227
- .map((skill) => {
228
- if (typeof skill === "string") {
229
- return { name: skill, source: DEFAULT_SKILL_SOURCE_REPO };
230
- }
231
- return skill;
232
- })
233
- .filter((skill) => !!skill.name &&
234
- !bannedSubstrings.some((bad) => skill.name.includes(bad)));
235
- return uniqueSortedSkills(normalized);
236
- }
237
- async function safeGetPayload(octokit, gistId) {
238
- try {
239
- return await (0, gistService_1.getSkillhubPayload)(octokit, gistId);
324
+ if (plan.uploadPayload) {
325
+ await (0, gistService_1.updateSkillhubGist)(octokit, gistId, plan.uploadPayload);
240
326
  }
241
- catch {
242
- return null;
327
+ const summary = createSummary({
328
+ mode: "auto",
329
+ dryRun: false,
330
+ gistFound: true,
331
+ gistCreated: false,
332
+ remoteNewer: plan.isRemoteNewer,
333
+ uploaded: plan.uploadPayload ? 1 : 0,
334
+ installPlanned: plan.installCandidates.length,
335
+ installed: installResult.succeeded.length,
336
+ removePlanned: 0,
337
+ removed: 0,
338
+ failed,
339
+ lastSyncAtUpdated: false,
340
+ });
341
+ if (failed.length === 0) {
342
+ await config_1.configStore.setLastSyncAt(nowIso);
343
+ summary.lastSyncAtUpdated = true;
243
344
  }
345
+ (0, output_1.emitOutput)(summary, asJson, formatSyncSummary);
346
+ return finalizeWithFailures(summary, asJson);
244
347
  }
245
- function uniqueSortedSkills(skills) {
246
- const seen = new Set();
247
- const result = [];
248
- for (const skill of skills) {
249
- const key = `${skill.source}:${skill.name}`;
250
- if (!seen.has(key)) {
251
- seen.add(key);
252
- result.push(skill);
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);
376
+ return summary;
253
377
  }
254
- }
255
- return result.sort((a, b) => {
256
- if (a.source !== b.source) {
257
- return a.source.localeCompare(b.source);
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.");
258
381
  }
259
- return a.name.localeCompare(b.name);
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;
400
+ }
401
+ const plan = (0, syncCore_1.buildPushPlan)({
402
+ localPayload,
403
+ remotePayload: asPlanPayload(remotePayload),
404
+ nowIso,
260
405
  });
261
- }
262
- async function installSkills(skills) {
263
- const succeeded = [];
264
- const failed = [];
265
- for (const skill of skills) {
266
- try {
267
- const { stdout, stderr } = await execAsync(`npx skills add ${skill.source} --skill "${skill.name}" --global --yes`);
268
- const output = `${stdout ?? ""}\n${stderr ?? ""}`.trim();
269
- if (output) {
270
- console.log(output);
271
- }
272
- succeeded.push(skill);
273
- }
274
- catch (error) {
275
- const stdout = error?.stdout ?? "";
276
- const stderr = error?.stderr ?? "";
277
- const reason = `${stdout}\n${stderr}`.trim() || String(error);
278
- failed.push({ skill, reason });
279
- console.warn([
280
- `스킬 설치 실패: ${skill.name} (from ${skill.source})`,
281
- reason && ` └─ ${reason}`,
282
- ]
283
- .filter(Boolean)
284
- .join("\n"));
285
- }
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);
286
426
  }
287
- return { succeeded, failed };
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);
443
+ return summary;
288
444
  }
289
- async function applyUnionStrategy(params) {
290
- const localSkills = normalizeSkills(params.local.skills);
291
- const remoteSkills = normalizeSkills(params.remote.skills);
292
- const unionSkills = uniqueSortedSkills([...localSkills, ...remoteSkills]);
293
- const missingLocally = unionSkills.filter((skill) => !localSkills.some((local) => local.name === skill.name && local.source === skill.source));
294
- const payload = {
295
- skills: unionSkills,
296
- updatedAt: new Date().toISOString(),
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,
297
454
  };
298
- const { succeeded, failed } = await installSkills(missingLocally);
299
- await (0, gistService_1.updateSkillhubGist)(params.octokit, params.gistId, payload);
300
- const uploadCount = areSameSkills(remoteSkills, unionSkills) ? 0 : 1;
301
- const installCount = succeeded.length;
302
- const failedCount = failed.length;
303
- console.log(`업로드 ${uploadCount}건, 설치 ${installCount}건${failedCount ? ` (실패 ${failedCount}건은 로그 참고)` : ""}`);
304
- }
305
- async function applyLatestStrategy(params) {
306
- const localTime = Date.parse(params.local.updatedAt);
307
- const remoteTime = Date.parse(params.remote.updatedAt);
308
- const isRemoteNewer = Number.isFinite(remoteTime) && remoteTime > localTime;
309
- if (isRemoteNewer) {
310
- const localSkills = normalizeSkills(params.local.skills);
311
- const remoteSkills = normalizeSkills(params.remote.skills);
312
- const missingLocally = remoteSkills.filter((skill) => !localSkills.some((local) => local.name === skill.name && local.source === skill.source));
313
- const { succeeded, failed } = await installSkills(missingLocally);
314
- const failedCount = failed.length;
315
- console.log(`업로드 0건, 설치 ${succeeded.length}건${failedCount ? ` (실패 ${failedCount}건은 로그 참고)` : ""}`);
316
- return;
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.");
317
458
  }
318
- await (0, gistService_1.updateSkillhubGist)(params.octokit, params.gistId, params.local);
319
- console.log("업로드 1건, 설치 0건");
320
- }
321
- function areSameSkills(left, right) {
322
- const leftSorted = uniqueSortedSkills(left);
323
- const rightSorted = uniqueSortedSkills(right);
324
- if (leftSorted.length !== rightSorted.length) {
325
- return false;
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;
326
514
  }
327
- return leftSorted.every((skill, index) => skill.name === rightSorted[index].name &&
328
- skill.source === rightSorted[index].source);
515
+ (0, output_1.emitOutput)(summary, asJson, formatSyncSummary);
516
+ return finalizeWithFailures(summary, asJson);
329
517
  }