@yonpark/skillhub-cli 0.1.2 → 0.2.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,30 @@
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
3
  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"));
4
+ const syncCore_1 = require("../core/syncCore");
12
5
  const config_1 = require("../service/config");
13
6
  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);
20
- const token = await config_1.configStore.getToken();
21
- if (!token) {
22
- throw new Error("You must login first. Run `skillhub login` and try again.");
23
- }
24
- const localSkills = await getLocalSkills();
25
- const localPayload = {
26
- skills: uniqueSortedSkills(localSkills),
27
- updatedAt: new Date().toISOString(),
28
- };
7
+ const skillsService_1 = require("../service/skillsService");
8
+ const output_1 = require("../utils/output");
9
+ function formatSyncSummary(summary) {
10
+ const prefix = summary.dryRun ? "Dry-run" : "Sync";
11
+ const failurePart = summary.failed.length > 0
12
+ ? ` (${summary.failed.length} failed - check logs or JSON output)`
13
+ : "";
14
+ 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)`;
17
+ const details = [
18
+ actionLine + failurePart,
19
+ `strategy=${summary.strategy}`,
20
+ `gistFound=${summary.gistFound}`,
21
+ `gistCreated=${summary.gistCreated}`,
22
+ `remoteNewer=${summary.remoteNewer === null ? "n/a" : String(summary.remoteNewer)}`,
23
+ `lastSyncAtUpdated=${summary.lastSyncAtUpdated}`,
24
+ ];
25
+ return details.join("\n");
26
+ }
27
+ async function resolveRemotePayload(token) {
29
28
  const octokit = (0, gistService_1.createOctokit)(token);
30
29
  let gistId = await config_1.configStore.getGistId();
31
30
  let remotePayload = null;
@@ -38,292 +37,155 @@ async function runSync(strategyInput) {
38
37
  if (!gistId) {
39
38
  const found = await (0, gistService_1.findSkillhubGist)(octokit);
40
39
  if (found?.id) {
41
- const foundId = found.id;
42
- gistId = foundId;
43
- await config_1.configStore.setGistId(foundId);
44
- remotePayload = await safeGetPayload(octokit, foundId);
45
- }
46
- }
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.");
40
+ gistId = found.id;
41
+ await config_1.configStore.setGistId(found.id);
42
+ remotePayload = await safeGetPayload(octokit, found.id);
51
43
  }
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
44
  }
72
- await applyUnionStrategy({
45
+ return {
73
46
  octokit,
74
47
  gistId,
75
- local: localPayload,
76
- remote: resolvedRemote,
77
- });
78
- }
79
- function parseStrategy(input) {
80
- if (input === "latest" || input === "union") {
81
- return input;
82
- }
83
- return DEFAULT_STRATEGY;
84
- }
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 [];
97
- }
98
- const fromList = parseSkillsListOutput(listOutput);
99
- if (fromList.length > 0) {
100
- return fromList;
101
- }
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;
110
- }
111
- }
112
- if (output.includes("No installed skills found") || listOutput.includes("No project skills found")) {
113
- return [];
114
- }
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
- : []),
146
- ];
147
- return [cwdPath, homePath, ...homeConfigPaths, ...windowsConfigPaths];
48
+ remotePayload: remotePayload ?? { skills: [], updatedAt: "" },
49
+ };
148
50
  }
149
- async function tryReadSkillsLock(lockPath) {
51
+ async function safeGetPayload(octokit, gistId) {
150
52
  try {
151
- const raw = await promises_1.default.readFile(lockPath, "utf-8");
152
- return parseSkillsLock(raw);
53
+ return await (0, gistService_1.getSkillhubPayload)(octokit, gistId);
153
54
  }
154
55
  catch {
155
56
  return null;
156
57
  }
157
58
  }
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
- });
59
+ function createSummaryFromPlan(params) {
60
+ 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,
173
72
  };
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);
184
- }
185
- return [];
186
73
  }
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 });
74
+ async function runSync(options = {}) {
75
+ const strategy = (0, syncCore_1.parseStrategy)(options.strategyInput);
76
+ const dryRun = options.dryRun === true;
77
+ 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.");
216
81
  }
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",
225
- ];
226
- const normalized = skills
227
- .map((skill) => {
228
- if (typeof skill === "string") {
229
- return { name: skill, source: DEFAULT_SKILL_SOURCE_REPO };
82
+ const nowIso = new Date().toISOString();
83
+ const localSkills = await (0, skillsService_1.getLocalSkills)();
84
+ const localPayload = {
85
+ skills: localSkills,
86
+ updatedAt: nowIso,
87
+ };
88
+ const { octokit, gistId, remotePayload } = await resolveRemotePayload(token);
89
+ const hasRemoteGist = Boolean(gistId);
90
+ if (!hasRemoteGist) {
91
+ if (dryRun) {
92
+ const summary = createSummaryFromPlan({
93
+ strategy,
94
+ dryRun: true,
95
+ gistFound: false,
96
+ gistCreated: false,
97
+ remoteNewer: null,
98
+ uploaded: 1,
99
+ installPlanned: 0,
100
+ installed: 0,
101
+ failed: [],
102
+ lastSyncAtUpdated: false,
103
+ });
104
+ (0, output_1.emitOutput)(summary, asJson, formatSyncSummary);
105
+ return summary;
230
106
  }
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);
240
- }
241
- catch {
242
- return null;
243
- }
244
- }
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);
107
+ const created = await (0, gistService_1.createSkillhubGist)(octokit, localPayload);
108
+ if (!created.id) {
109
+ throw new Error("Gist was created, but the ID could not be determined.");
253
110
  }
111
+ await config_1.configStore.setGistId(created.id);
112
+ await config_1.configStore.setLastSyncAt(nowIso);
113
+ const summary = createSummaryFromPlan({
114
+ strategy,
115
+ dryRun: false,
116
+ gistFound: false,
117
+ gistCreated: true,
118
+ remoteNewer: null,
119
+ uploaded: 1,
120
+ installPlanned: 0,
121
+ installed: 0,
122
+ failed: [],
123
+ lastSyncAtUpdated: true,
124
+ });
125
+ (0, output_1.emitOutput)(summary, asJson, formatSyncSummary);
126
+ return summary;
127
+ }
128
+ const lastSyncAt = await config_1.configStore.getLastSyncAt();
129
+ const plan = (0, syncCore_1.buildSyncPlan)({
130
+ strategy,
131
+ localPayload,
132
+ remotePayload,
133
+ lastSyncAt,
134
+ nowIso,
135
+ });
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));
143
+ if (dryRun) {
144
+ const summary = createSummaryFromPlan({
145
+ strategy,
146
+ dryRun: true,
147
+ gistFound: true,
148
+ gistCreated: false,
149
+ remoteNewer: strategy === "latest" ? plan.isRemoteNewer : null,
150
+ uploaded: plan.uploadPayload ? 1 : 0,
151
+ installPlanned: plan.installCandidates.length,
152
+ installed: 0,
153
+ failed: invalidInstallCandidates,
154
+ lastSyncAtUpdated: false,
155
+ });
156
+ (0, output_1.emitOutput)(summary, asJson, formatSyncSummary);
157
+ return summary;
254
158
  }
255
- return result.sort((a, b) => {
256
- if (a.source !== b.source) {
257
- return a.source.localeCompare(b.source);
258
- }
259
- return a.name.localeCompare(b.name);
159
+ const installResult = await (0, skillsService_1.installSkills)(validInstallCandidates, {
160
+ verbose: !asJson,
260
161
  });
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"));
162
+ const failed = [...invalidInstallCandidates, ...installResult.failed];
163
+ if (plan.uploadPayload) {
164
+ await (0, gistService_1.updateSkillhubGist)(octokit, gistId, plan.uploadPayload);
165
+ }
166
+ const summary = createSummaryFromPlan({
167
+ strategy,
168
+ dryRun: false,
169
+ gistFound: true,
170
+ gistCreated: false,
171
+ remoteNewer: strategy === "latest" ? plan.isRemoteNewer : null,
172
+ uploaded: plan.uploadPayload ? 1 : 0,
173
+ installPlanned: plan.installCandidates.length,
174
+ installed: installResult.succeeded.length,
175
+ failed,
176
+ lastSyncAtUpdated: false,
177
+ });
178
+ if (failed.length === 0) {
179
+ await config_1.configStore.setLastSyncAt(nowIso);
180
+ summary.lastSyncAtUpdated = true;
181
+ }
182
+ (0, output_1.emitOutput)(summary, asJson, formatSyncSummary);
183
+ if (failed.length > 0) {
184
+ if (asJson) {
185
+ process.exitCode = 1;
186
+ return summary;
285
187
  }
188
+ throw new Error(`Sync completed with ${failed.length} failed install(s). Check logs above.`);
286
189
  }
287
- return { succeeded, failed };
288
- }
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(),
297
- };
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;
317
- }
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;
326
- }
327
- return leftSorted.every((skill, index) => skill.name === rightSorted[index].name &&
328
- skill.source === rightSorted[index].source);
190
+ return summary;
329
191
  }
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseStrategy = parseStrategy;
4
+ exports.parseTimestamp = parseTimestamp;
5
+ exports.normalizeSkills = normalizeSkills;
6
+ exports.uniqueSortedSkills = uniqueSortedSkills;
7
+ exports.areSameSkills = areSameSkills;
8
+ exports.buildSyncPlan = buildSyncPlan;
9
+ const DEFAULT_SKILL_SOURCE_REPO = "vercel-labs/agent-skills";
10
+ const BANNED_SKILL_NAME_SUBSTRINGS = [
11
+ "No global skills found",
12
+ "Try listing project skills without -g",
13
+ "No project skills found",
14
+ "Try listing global skills",
15
+ ];
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
+ function parseTimestamp(value) {
26
+ if (!value) {
27
+ return null;
28
+ }
29
+ const parsed = Date.parse(value);
30
+ return Number.isFinite(parsed) ? parsed : null;
31
+ }
32
+ function normalizeSkills(skills) {
33
+ const normalized = skills
34
+ .map((skill) => {
35
+ if (typeof skill === "string") {
36
+ return { name: skill, source: DEFAULT_SKILL_SOURCE_REPO };
37
+ }
38
+ return {
39
+ name: String(skill?.name ?? ""),
40
+ source: String(skill?.source ?? DEFAULT_SKILL_SOURCE_REPO),
41
+ };
42
+ })
43
+ .filter((skill) => skill.name.length > 0 &&
44
+ !BANNED_SKILL_NAME_SUBSTRINGS.some((bad) => skill.name.includes(bad)));
45
+ return uniqueSortedSkills(normalized);
46
+ }
47
+ function uniqueSortedSkills(skills) {
48
+ const seen = new Set();
49
+ const result = [];
50
+ for (const skill of skills) {
51
+ const key = `${skill.source}:${skill.name}`;
52
+ if (!seen.has(key)) {
53
+ seen.add(key);
54
+ result.push(skill);
55
+ }
56
+ }
57
+ return result.sort((a, b) => {
58
+ if (a.source !== b.source) {
59
+ return a.source.localeCompare(b.source);
60
+ }
61
+ return a.name.localeCompare(b.name);
62
+ });
63
+ }
64
+ function areSameSkills(left, right) {
65
+ const leftSorted = uniqueSortedSkills(left);
66
+ const rightSorted = uniqueSortedSkills(right);
67
+ if (leftSorted.length !== rightSorted.length) {
68
+ return false;
69
+ }
70
+ return leftSorted.every((skill, index) => skill.name === rightSorted[index].name &&
71
+ skill.source === rightSorted[index].source);
72
+ }
73
+ function buildSyncPlan(params) {
74
+ const localSkills = normalizeSkills(params.localPayload.skills);
75
+ 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,
92
+ };
93
+ }
94
+ const lastSyncTime = parseTimestamp(params.lastSyncAt) ?? 0;
95
+ const remoteTime = parseTimestamp(params.remotePayload.updatedAt);
96
+ const isRemoteNewer = remoteTime !== null && remoteTime > lastSyncTime;
97
+ if (isRemoteNewer) {
98
+ const installCandidates = remoteSkills.filter((skill) => !localSkills.some((local) => local.name === skill.name && local.source === skill.source));
99
+ return {
100
+ strategy: "latest",
101
+ localSkills,
102
+ remoteSkills,
103
+ installCandidates,
104
+ uploadPayload: null,
105
+ isRemoteNewer,
106
+ };
107
+ }
108
+ const uploadPayload = areSameSkills(localSkills, remoteSkills)
109
+ ? null
110
+ : {
111
+ skills: localSkills,
112
+ updatedAt: params.nowIso,
113
+ };
114
+ return {
115
+ strategy: "latest",
116
+ localSkills,
117
+ remoteSkills,
118
+ installCandidates: [],
119
+ uploadPayload,
120
+ isRemoteNewer,
121
+ };
122
+ }