@ryanreh99/skills-sync 1.0.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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/dist/assets/contracts/build/bundle.schema.json +76 -0
  4. package/dist/assets/contracts/inputs/config.schema.json +13 -0
  5. package/dist/assets/contracts/inputs/mcp-servers.schema.json +56 -0
  6. package/dist/assets/contracts/inputs/pack-manifest.schema.json +33 -0
  7. package/dist/assets/contracts/inputs/pack-sources.schema.json +47 -0
  8. package/dist/assets/contracts/inputs/profile.schema.json +21 -0
  9. package/dist/assets/contracts/inputs/upstreams.schema.json +45 -0
  10. package/dist/assets/contracts/runtime/targets.schema.json +120 -0
  11. package/dist/assets/contracts/state/upstreams-lock.schema.json +38 -0
  12. package/dist/assets/manifests/targets.linux.json +27 -0
  13. package/dist/assets/manifests/targets.macos.json +27 -0
  14. package/dist/assets/manifests/targets.windows.json +27 -0
  15. package/dist/assets/seed/config.json +3 -0
  16. package/dist/assets/seed/packs/personal/mcp/servers.json +20 -0
  17. package/dist/assets/seed/packs/personal/pack.json +7 -0
  18. package/dist/assets/seed/packs/personal/sources.json +31 -0
  19. package/dist/assets/seed/profiles/personal.json +4 -0
  20. package/dist/assets/seed/upstreams.json +23 -0
  21. package/dist/cli.js +532 -0
  22. package/dist/index.js +27 -0
  23. package/dist/lib/adapters/claude.js +49 -0
  24. package/dist/lib/adapters/codex.js +239 -0
  25. package/dist/lib/adapters/common.js +114 -0
  26. package/dist/lib/adapters/copilot.js +53 -0
  27. package/dist/lib/adapters/cursor.js +53 -0
  28. package/dist/lib/adapters/gemini.js +52 -0
  29. package/dist/lib/agents.js +888 -0
  30. package/dist/lib/bindings.js +510 -0
  31. package/dist/lib/build.js +190 -0
  32. package/dist/lib/bundle.js +165 -0
  33. package/dist/lib/config.js +324 -0
  34. package/dist/lib/core.js +447 -0
  35. package/dist/lib/detect.js +56 -0
  36. package/dist/lib/doctor.js +504 -0
  37. package/dist/lib/init.js +292 -0
  38. package/dist/lib/inventory.js +235 -0
  39. package/dist/lib/manage.js +463 -0
  40. package/dist/lib/mcp-config.js +264 -0
  41. package/dist/lib/profile-transfer.js +221 -0
  42. package/dist/lib/upstreams.js +782 -0
  43. package/docs/agent-storage-map.md +153 -0
  44. package/docs/architecture.md +117 -0
  45. package/docs/changelog.md +12 -0
  46. package/docs/commands.md +94 -0
  47. package/docs/contracts.md +112 -0
  48. package/docs/homebrew.md +46 -0
  49. package/docs/quickstart.md +14 -0
  50. package/docs/roadmap.md +5 -0
  51. package/docs/security.md +32 -0
  52. package/docs/user-guide.md +257 -0
  53. package/package.json +61 -0
@@ -0,0 +1,463 @@
1
+ import fs from "fs-extra";
2
+ import path from "node:path";
3
+ import {
4
+ LOCKFILE_PATH,
5
+ SCHEMAS,
6
+ UPSTREAMS_CONFIG_PATHS,
7
+ assertJsonFileMatchesSchema,
8
+ assertObjectMatchesSchema,
9
+ logInfo,
10
+ logWarn,
11
+ normalizeDestPrefix,
12
+ normalizeRepoPath,
13
+ writeJsonFile
14
+ } from "./core.js";
15
+ import { listAvailableProfiles, loadPackSources, resolvePack, resolveProfile } from "./config.js";
16
+ import { collectSourcePlanning, loadLockfile, loadUpstreamsConfig, sortPins } from "./upstreams.js";
17
+
18
+ function normalizeRequiredText(value, label) {
19
+ if (typeof value !== "string" || value.trim().length === 0) {
20
+ throw new Error(`${label} must be a non-empty string.`);
21
+ }
22
+ return value.trim();
23
+ }
24
+
25
+ function normalizeOptionalText(value) {
26
+ if (typeof value !== "string") {
27
+ return null;
28
+ }
29
+ const normalized = value.trim();
30
+ return normalized.length > 0 ? normalized : null;
31
+ }
32
+
33
+ function cloneSourcesDocument(sources) {
34
+ return {
35
+ imports: Array.isArray(sources?.imports)
36
+ ? sources.imports.map((entry) => ({
37
+ ...entry,
38
+ paths: Array.isArray(entry.paths) ? [...entry.paths] : []
39
+ }))
40
+ : []
41
+ };
42
+ }
43
+
44
+ function normalizeImportRef(importEntry, upstreamDefaultRef) {
45
+ if (typeof importEntry.ref === "string" && importEntry.ref.trim().length > 0) {
46
+ return importEntry.ref.trim();
47
+ }
48
+ return upstreamDefaultRef;
49
+ }
50
+
51
+ function normalizedImportPaths(importEntry) {
52
+ const normalized = new Set();
53
+ for (const rawPath of Array.isArray(importEntry.paths) ? importEntry.paths : []) {
54
+ normalized.add(normalizeRepoPath(rawPath, "imports[].paths[]"));
55
+ }
56
+ return Array.from(normalized.values()).sort((left, right) => left.localeCompare(right));
57
+ }
58
+
59
+ function sortImports(imports) {
60
+ imports.sort((left, right) => {
61
+ const leftRef = typeof left.ref === "string" && left.ref.trim().length > 0 ? left.ref.trim() : "";
62
+ const rightRef = typeof right.ref === "string" && right.ref.trim().length > 0 ? right.ref.trim() : "";
63
+ const leftPrefix = typeof left.destPrefix === "string" ? left.destPrefix : "";
64
+ const rightPrefix = typeof right.destPrefix === "string" ? right.destPrefix : "";
65
+ const leftKey = `${left.upstream}::${leftRef}::${leftPrefix}::${left.paths.join("|")}`;
66
+ const rightKey = `${right.upstream}::${rightRef}::${rightPrefix}::${right.paths.join("|")}`;
67
+ return leftKey.localeCompare(rightKey);
68
+ });
69
+ }
70
+
71
+ async function writeValidatedSources(sourcesPath, sourcesDoc, upstreamById) {
72
+ await assertObjectMatchesSchema(sourcesDoc, SCHEMAS.packSources, "pack sources");
73
+ collectSourcePlanning(sourcesDoc, upstreamById);
74
+ await writeJsonFile(sourcesPath, sourcesDoc);
75
+ }
76
+
77
+ async function loadEditableUpstreamsConfig() {
78
+ const loaded = await loadUpstreamsConfig();
79
+ return {
80
+ upstreams: loaded.config.upstreams.map((item) => ({
81
+ id: item.id,
82
+ type: item.type,
83
+ repo: item.repo,
84
+ defaultRef: item.defaultRef
85
+ })),
86
+ byId: loaded.byId
87
+ };
88
+ }
89
+
90
+ async function writeValidatedUpstreamsConfig(upstreams) {
91
+ const next = { upstreams: [...upstreams] };
92
+ next.upstreams.sort((left, right) => left.id.localeCompare(right.id));
93
+ await assertObjectMatchesSchema(next, SCHEMAS.upstreams, "upstreams config");
94
+ await writeJsonFile(UPSTREAMS_CONFIG_PATHS.local, next);
95
+ }
96
+
97
+ async function findProfilesUsingUpstream(upstreamId) {
98
+ const profiles = await listAvailableProfiles();
99
+ const consumers = [];
100
+ for (const profile of profiles) {
101
+ try {
102
+ const resolved = await resolveProfile(profile.name);
103
+ const packRoot = await resolvePack(resolved.profile);
104
+ const { sources } = await loadPackSources(packRoot);
105
+ const hasReference = Array.isArray(sources.imports)
106
+ ? sources.imports.some((entry) => entry?.upstream === upstreamId)
107
+ : false;
108
+ if (hasReference) {
109
+ consumers.push(profile.name);
110
+ }
111
+ } catch {
112
+ // Ignore invalid profiles here; doctor/build will report them.
113
+ }
114
+ }
115
+ return consumers.sort((left, right) => left.localeCompare(right));
116
+ }
117
+
118
+ export async function cmdUpstreamAdd({ id, repo, defaultRef, type }) {
119
+ const upstreamId = normalizeRequiredText(id, "Upstream id");
120
+ const upstreamRepo = normalizeRequiredText(repo, "Upstream repo");
121
+ const upstreamRef = normalizeRequiredText(defaultRef || "main", "Upstream default ref");
122
+ const upstreamType = normalizeRequiredText(type || "git", "Upstream type");
123
+
124
+ if (upstreamType !== "git") {
125
+ throw new Error("Only upstream type 'git' is supported.");
126
+ }
127
+
128
+ const editable = await loadEditableUpstreamsConfig();
129
+ if (editable.byId.has(upstreamId)) {
130
+ throw new Error(`Upstream '${upstreamId}' already exists.`);
131
+ }
132
+
133
+ editable.upstreams.push({
134
+ id: upstreamId,
135
+ type: upstreamType,
136
+ repo: upstreamRepo,
137
+ defaultRef: upstreamRef
138
+ });
139
+
140
+ await writeValidatedUpstreamsConfig(editable.upstreams);
141
+ logInfo(`Added upstream '${upstreamId}'.`);
142
+ }
143
+
144
+ export async function cmdUpstreamRemove({ id }) {
145
+ const upstreamId = normalizeRequiredText(id, "Upstream id");
146
+ const editable = await loadEditableUpstreamsConfig();
147
+ const before = editable.upstreams.length;
148
+ const nextUpstreams = editable.upstreams.filter((item) => item.id !== upstreamId);
149
+
150
+ if (before === nextUpstreams.length) {
151
+ throw new Error(`Upstream '${upstreamId}' not found.`);
152
+ }
153
+
154
+ await writeValidatedUpstreamsConfig(nextUpstreams);
155
+ logInfo(`Removed upstream '${upstreamId}'.`);
156
+
157
+ const lockState = await loadLockfile();
158
+ if (lockState.exists) {
159
+ const initialPins = lockState.lock.pins.length;
160
+ lockState.lock.pins = lockState.lock.pins.filter((pin) => pin.upstream !== upstreamId);
161
+ const removedPins = initialPins - lockState.lock.pins.length;
162
+ if (removedPins > 0) {
163
+ sortPins(lockState.lock);
164
+ await writeJsonFile(LOCKFILE_PATH, lockState.lock);
165
+ logInfo(`Removed ${removedPins} lock pin(s) for upstream '${upstreamId}'.`);
166
+ }
167
+ }
168
+
169
+ const consumers = await findProfilesUsingUpstream(upstreamId);
170
+ if (consumers.length > 0) {
171
+ logWarn(
172
+ `Upstream '${upstreamId}' is still referenced by profile(s): ${consumers.join(", ")}. ` +
173
+ "Update profile imports before the next build."
174
+ );
175
+ }
176
+ }
177
+
178
+ export async function cmdProfileAddSkill({ profile, upstream, skillPath, ref, destPrefix }) {
179
+ const profileName = normalizeRequiredText(profile, "Profile name");
180
+ const upstreamId = normalizeRequiredText(upstream, "Upstream id");
181
+ const repoPath = normalizeRepoPath(skillPath, "Skill path");
182
+
183
+ const { profile: profileDoc } = await resolveProfile(profileName);
184
+ const packRoot = await resolvePack(profileDoc);
185
+ const sourcesPath = path.join(packRoot, "sources.json");
186
+
187
+ const upstreams = await loadUpstreamsConfig();
188
+ const upstreamDoc = upstreams.byId.get(upstreamId);
189
+ if (!upstreamDoc) {
190
+ throw new Error(`Unknown upstream '${upstreamId}'.`);
191
+ }
192
+
193
+ const effectiveRef = ref ? normalizeRequiredText(ref, "Ref") : upstreamDoc.defaultRef;
194
+ const effectiveDestPrefix = normalizeDestPrefix(destPrefix, upstreamId, "Skill import");
195
+ const { sources } = await loadPackSources(packRoot);
196
+ const nextSources = cloneSourcesDocument(sources);
197
+
198
+ let added = false;
199
+ for (const importEntry of nextSources.imports) {
200
+ if (importEntry.upstream !== upstreamId) {
201
+ continue;
202
+ }
203
+ const entryRef = normalizeImportRef(importEntry, upstreamDoc.defaultRef);
204
+ const entryDestPrefix = normalizeDestPrefix(importEntry.destPrefix, upstreamId, "imports[]");
205
+ if (entryRef !== effectiveRef || entryDestPrefix !== effectiveDestPrefix) {
206
+ continue;
207
+ }
208
+
209
+ const currentPaths = normalizedImportPaths(importEntry);
210
+ if (currentPaths.includes(repoPath)) {
211
+ logInfo(`Profile '${profileName}' already imports '${repoPath}' from ${upstreamId}@${effectiveRef}.`);
212
+ return;
213
+ }
214
+
215
+ importEntry.ref = effectiveRef;
216
+ importEntry.destPrefix = effectiveDestPrefix;
217
+ importEntry.paths = [...currentPaths, repoPath].sort((left, right) => left.localeCompare(right));
218
+ added = true;
219
+ break;
220
+ }
221
+
222
+ if (!added) {
223
+ nextSources.imports.push({
224
+ upstream: upstreamId,
225
+ ref: effectiveRef,
226
+ paths: [repoPath],
227
+ destPrefix: effectiveDestPrefix
228
+ });
229
+ }
230
+
231
+ for (const importEntry of nextSources.imports) {
232
+ importEntry.paths = normalizedImportPaths(importEntry);
233
+ }
234
+ sortImports(nextSources.imports);
235
+ await writeValidatedSources(sourcesPath, nextSources, upstreams.byId);
236
+
237
+ logInfo(
238
+ `Added skill import '${repoPath}' to profile '${profileName}' from ${upstreamId}@${effectiveRef} ` +
239
+ `with destPrefix '${effectiveDestPrefix}'.`
240
+ );
241
+ }
242
+
243
+ export async function cmdProfileRemoveSkill({ profile, upstream, skillPath, ref, destPrefix }) {
244
+ const profileName = normalizeRequiredText(profile, "Profile name");
245
+ const upstreamId = normalizeRequiredText(upstream, "Upstream id");
246
+ const repoPath = normalizeRepoPath(skillPath, "Skill path");
247
+
248
+ const { profile: profileDoc } = await resolveProfile(profileName);
249
+ const packRoot = await resolvePack(profileDoc);
250
+ const sourcesPath = path.join(packRoot, "sources.json");
251
+
252
+ const upstreams = await loadUpstreamsConfig();
253
+ const upstreamDoc = upstreams.byId.get(upstreamId);
254
+ if (!upstreamDoc) {
255
+ throw new Error(`Unknown upstream '${upstreamId}'.`);
256
+ }
257
+
258
+ const desiredRef = ref ? normalizeRequiredText(ref, "Ref") : null;
259
+ const desiredDestPrefix = destPrefix ? normalizeDestPrefix(destPrefix, upstreamId, "destPrefix") : null;
260
+ const { sources } = await loadPackSources(packRoot);
261
+ const nextSources = cloneSourcesDocument(sources);
262
+
263
+ let removedCount = 0;
264
+ const filteredImports = [];
265
+ for (const importEntry of nextSources.imports) {
266
+ if (importEntry.upstream !== upstreamId) {
267
+ filteredImports.push(importEntry);
268
+ continue;
269
+ }
270
+
271
+ const entryRef = normalizeImportRef(importEntry, upstreamDoc.defaultRef);
272
+ if (desiredRef && entryRef !== desiredRef) {
273
+ filteredImports.push(importEntry);
274
+ continue;
275
+ }
276
+
277
+ const entryDestPrefix = normalizeDestPrefix(importEntry.destPrefix, upstreamId, "imports[]");
278
+ if (desiredDestPrefix && entryDestPrefix !== desiredDestPrefix) {
279
+ filteredImports.push(importEntry);
280
+ continue;
281
+ }
282
+
283
+ const remainingPaths = [];
284
+ for (const existingPath of normalizedImportPaths(importEntry)) {
285
+ if (existingPath === repoPath) {
286
+ removedCount += 1;
287
+ } else {
288
+ remainingPaths.push(existingPath);
289
+ }
290
+ }
291
+
292
+ if (remainingPaths.length === 0) {
293
+ continue;
294
+ }
295
+ filteredImports.push({
296
+ ...importEntry,
297
+ ref: entryRef,
298
+ destPrefix: entryDestPrefix,
299
+ paths: remainingPaths
300
+ });
301
+ }
302
+
303
+ if (removedCount === 0) {
304
+ throw new Error(
305
+ `Skill import '${repoPath}' not found for profile '${profileName}' and upstream '${upstreamId}'.`
306
+ );
307
+ }
308
+
309
+ nextSources.imports = filteredImports;
310
+ sortImports(nextSources.imports);
311
+ await writeValidatedSources(sourcesPath, nextSources, upstreams.byId);
312
+
313
+ logInfo(`Removed ${removedCount} skill import entr${removedCount === 1 ? "y" : "ies"} from profile '${profileName}'.`);
314
+ }
315
+
316
+ function normalizeMcpArgs(rawArgs) {
317
+ if (!rawArgs) {
318
+ return [];
319
+ }
320
+ if (!Array.isArray(rawArgs)) {
321
+ return [String(rawArgs)];
322
+ }
323
+ return rawArgs.map((value) => String(value));
324
+ }
325
+
326
+ function normalizeMcpEnvMap(rawEnv) {
327
+ if (!rawEnv || typeof rawEnv !== "object" || Array.isArray(rawEnv)) {
328
+ return {};
329
+ }
330
+ const normalized = {};
331
+ const keys = Object.keys(rawEnv).sort((left, right) => left.localeCompare(right));
332
+ for (const key of keys) {
333
+ if (key.length === 0) {
334
+ continue;
335
+ }
336
+ normalized[key] = String(rawEnv[key]);
337
+ }
338
+ return normalized;
339
+ }
340
+
341
+ function normalizeMcpEnvEntries(rawEntries) {
342
+ if (!rawEntries) {
343
+ return {};
344
+ }
345
+ const entries = Array.isArray(rawEntries) ? rawEntries : [rawEntries];
346
+ const parsed = {};
347
+ for (const rawEntry of entries) {
348
+ const text = String(rawEntry ?? "");
349
+ const equalsAt = text.indexOf("=");
350
+ if (equalsAt <= 0) {
351
+ throw new Error("MCP env values must be in KEY=VALUE format.");
352
+ }
353
+ const key = text.slice(0, equalsAt).trim();
354
+ if (key.length === 0) {
355
+ throw new Error("MCP env variable name cannot be empty.");
356
+ }
357
+ parsed[key] = text.slice(equalsAt + 1);
358
+ }
359
+ return normalizeMcpEnvMap(parsed);
360
+ }
361
+
362
+ function normalizeMcpServersDocument(document) {
363
+ const serverNames = Object.keys(document.servers ?? {}).sort((left, right) => left.localeCompare(right));
364
+ const servers = {};
365
+ for (const name of serverNames) {
366
+ const server = document.servers[name] ?? {};
367
+ if (typeof server.url === "string" && server.url.trim().length > 0) {
368
+ servers[name] = {
369
+ url: server.url.trim()
370
+ };
371
+ continue;
372
+ }
373
+ const normalizedServer = {
374
+ command: server.command,
375
+ args: Array.isArray(server.args) ? server.args : []
376
+ };
377
+ const env = normalizeMcpEnvMap(server.env);
378
+ if (Object.keys(env).length > 0) {
379
+ normalizedServer.env = env;
380
+ }
381
+ servers[name] = normalizedServer;
382
+ }
383
+ return { servers };
384
+ }
385
+
386
+ async function loadMcpServersForProfile(profileName) {
387
+ const { profile } = await resolveProfile(profileName);
388
+ const packRoot = await resolvePack(profile);
389
+ const mcpPath = path.join(packRoot, "mcp", "servers.json");
390
+ let document = { servers: {} };
391
+ if (await fs.pathExists(mcpPath)) {
392
+ document = await assertJsonFileMatchesSchema(mcpPath, SCHEMAS.mcpServers);
393
+ }
394
+ return {
395
+ mcpPath,
396
+ document: normalizeMcpServersDocument(document)
397
+ };
398
+ }
399
+
400
+ export async function cmdProfileAddMcp({ profile, name, command, url, args, env }) {
401
+ const profileName = normalizeRequiredText(profile, "Profile name");
402
+ const serverName = normalizeRequiredText(name, "MCP server name");
403
+ const serverCommand = normalizeOptionalText(command);
404
+ const serverUrl = normalizeOptionalText(url);
405
+
406
+ if ((serverCommand ? 1 : 0) + (serverUrl ? 1 : 0) !== 1) {
407
+ throw new Error("Provide exactly one of --command or --url for profile add-mcp.");
408
+ }
409
+
410
+ const serverArgs = normalizeMcpArgs(args);
411
+ const serverEnv = normalizeMcpEnvEntries(env);
412
+
413
+ if (serverUrl && serverArgs.length > 0) {
414
+ throw new Error("--args cannot be used with --url.");
415
+ }
416
+ if (serverUrl && Object.keys(serverEnv).length > 0) {
417
+ throw new Error("--env cannot be used with --url.");
418
+ }
419
+
420
+ const { mcpPath, document } = await loadMcpServersForProfile(profileName);
421
+ const existed = Object.prototype.hasOwnProperty.call(document.servers, serverName);
422
+ let nextServer = null;
423
+ if (serverUrl) {
424
+ nextServer = {
425
+ url: serverUrl
426
+ };
427
+ } else {
428
+ nextServer = {
429
+ command: serverCommand,
430
+ args: serverArgs
431
+ };
432
+ if (Object.keys(serverEnv).length > 0) {
433
+ nextServer.env = serverEnv;
434
+ }
435
+ }
436
+ document.servers[serverName] = nextServer;
437
+
438
+ const normalized = normalizeMcpServersDocument(document);
439
+ await assertObjectMatchesSchema(normalized, SCHEMAS.mcpServers, mcpPath);
440
+ await writeJsonFile(mcpPath, normalized);
441
+
442
+ if (existed) {
443
+ logInfo(`Updated MCP server '${serverName}' for profile '${profileName}'.`);
444
+ } else {
445
+ logInfo(`Added MCP server '${serverName}' for profile '${profileName}'.`);
446
+ }
447
+ }
448
+
449
+ export async function cmdProfileRemoveMcp({ profile, name }) {
450
+ const profileName = normalizeRequiredText(profile, "Profile name");
451
+ const serverName = normalizeRequiredText(name, "MCP server name");
452
+ const { mcpPath, document } = await loadMcpServersForProfile(profileName);
453
+
454
+ if (!Object.prototype.hasOwnProperty.call(document.servers, serverName)) {
455
+ throw new Error(`MCP server '${serverName}' not found in profile '${profileName}'.`);
456
+ }
457
+ delete document.servers[serverName];
458
+
459
+ const normalized = normalizeMcpServersDocument(document);
460
+ await assertObjectMatchesSchema(normalized, SCHEMAS.mcpServers, mcpPath);
461
+ await writeJsonFile(mcpPath, normalized);
462
+ logInfo(`Removed MCP server '${serverName}' from profile '${profileName}'.`);
463
+ }