@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,447 @@
1
+ import Ajv from "ajv";
2
+ import crypto from "node:crypto";
3
+ import { fileURLToPath } from "node:url";
4
+ import fs from "fs-extra";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = path.dirname(__filename);
10
+
11
+ function runtimeHomeDir() {
12
+ return process.env.USERPROFILE || process.env.HOME || os.homedir();
13
+ }
14
+
15
+ function expandUserPath(rawPath) {
16
+ if (typeof rawPath !== "string") {
17
+ return rawPath;
18
+ }
19
+ const home = runtimeHomeDir();
20
+ if (rawPath === "~") {
21
+ return home;
22
+ }
23
+ if (rawPath.startsWith("~/") || rawPath.startsWith("~\\")) {
24
+ return path.join(home, rawPath.slice(2));
25
+ }
26
+ return rawPath
27
+ .replace(/\$\{HOME\}/g, home)
28
+ .replace(/\$HOME/g, home)
29
+ .replace(/%USERPROFILE%/gi, home);
30
+ }
31
+
32
+ function resolveSkillsSyncHome() {
33
+ const configured = process.env.SKILLS_SYNC_HOME;
34
+ if (typeof configured === "string" && configured.trim().length > 0) {
35
+ return path.resolve(expandUserPath(configured.trim()));
36
+ }
37
+ return path.join(runtimeHomeDir(), ".skills-sync");
38
+ }
39
+
40
+ export const PACKAGE_ROOT = path.resolve(__dirname, "..");
41
+ export const ASSETS_ROOT = path.join(PACKAGE_ROOT, "assets");
42
+ export const SKILLS_SYNC_HOME = resolveSkillsSyncHome();
43
+ export const RUNTIME_INTERNAL_ROOT = path.join(SKILLS_SYNC_HOME, "internal");
44
+ export const LOCAL_OVERRIDES_ROOT = path.join(SKILLS_SYNC_HOME, "workspace");
45
+ export const MANAGED_BY = "skills-sync";
46
+ export const TOOL_NAMES = ["codex", "claude", "cursor", "copilot", "gemini"];
47
+ export const SKILLS_TOOL_NAMES = ["codex", "claude", "cursor", "copilot", "gemini"];
48
+ export const CACHE_ROOT = path.join(SKILLS_SYNC_HOME, "upstreams_cache");
49
+ export const MCP_MANAGED_PREFIX = "skills-sync__";
50
+ export const CODEX_MCP_BLOCK_START = "# skills-sync managed mcp start";
51
+ export const CODEX_MCP_BLOCK_END = "# skills-sync managed mcp end";
52
+
53
+ export function getTargetManifestPath(osName) {
54
+ return path.join(ASSETS_ROOT, "manifests", `targets.${osName}.json`);
55
+ }
56
+
57
+ export const SCHEMAS = {
58
+ profile: path.join(ASSETS_ROOT, "contracts", "inputs", "profile.schema.json"),
59
+ packManifest: path.join(ASSETS_ROOT, "contracts", "inputs", "pack-manifest.schema.json"),
60
+ mcpServers: path.join(ASSETS_ROOT, "contracts", "inputs", "mcp-servers.schema.json"),
61
+ packSources: path.join(ASSETS_ROOT, "contracts", "inputs", "pack-sources.schema.json"),
62
+ upstreams: path.join(ASSETS_ROOT, "contracts", "inputs", "upstreams.schema.json"),
63
+ config: path.join(ASSETS_ROOT, "contracts", "inputs", "config.schema.json"),
64
+ targets: path.join(ASSETS_ROOT, "contracts", "runtime", "targets.schema.json"),
65
+ upstreamsLock: path.join(ASSETS_ROOT, "contracts", "state", "upstreams-lock.schema.json"),
66
+ bundle: path.join(ASSETS_ROOT, "contracts", "build", "bundle.schema.json")
67
+ };
68
+
69
+ export const CONFIG_PATH = path.join(LOCAL_OVERRIDES_ROOT, "config.json");
70
+
71
+ export const UPSTREAMS_CONFIG_PATHS = {
72
+ local: path.join(LOCAL_OVERRIDES_ROOT, "upstreams.json"),
73
+ seed: path.join(ASSETS_ROOT, "seed", "upstreams.json")
74
+ };
75
+
76
+ export const LOCKFILE_PATH = path.join(LOCAL_OVERRIDES_ROOT, "upstreams.lock.json");
77
+
78
+ const ajv = new Ajv({ allErrors: true, strict: false });
79
+ const validatorCache = new Map();
80
+
81
+ export function logInfo(message) {
82
+ process.stdout.write(`[skills-sync] ${message}\n`);
83
+ }
84
+
85
+ export function logWarn(message) {
86
+ process.stderr.write(`[skills-sync] WARN: ${message}\n`);
87
+ }
88
+
89
+ export function normalizePathForCompare(inputPath) {
90
+ const normalized = path.resolve(inputPath);
91
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
92
+ }
93
+
94
+ export function pathsEqual(left, right) {
95
+ return normalizePathForCompare(left) === normalizePathForCompare(right);
96
+ }
97
+
98
+ export function isInsidePath(basePath, candidatePath) {
99
+ const base = normalizePathForCompare(basePath);
100
+ const candidate = normalizePathForCompare(candidatePath);
101
+ const sep = process.platform === "win32" ? "\\" : "/";
102
+ return candidate === base || candidate.startsWith(`${base}${sep}`);
103
+ }
104
+
105
+ export function detectOsName() {
106
+ if (process.platform === "win32") {
107
+ return "windows";
108
+ }
109
+ if (process.platform === "darwin") {
110
+ return "macos";
111
+ }
112
+ if (process.platform === "linux") {
113
+ return "linux";
114
+ }
115
+ throw new Error("Unsupported host OS. Expected Windows, macOS, or Linux.");
116
+ }
117
+
118
+ export function toAbsolutePath(inputPath, basePath = SKILLS_SYNC_HOME) {
119
+ return path.isAbsolute(inputPath) ? path.resolve(inputPath) : path.resolve(basePath, inputPath);
120
+ }
121
+
122
+ export function expandTargetPath(rawPath, osName) {
123
+ let expanded = rawPath;
124
+ const homePath = process.env.HOME || os.homedir();
125
+ const userProfile = process.env.USERPROFILE || homePath;
126
+
127
+ if (osName === "windows") {
128
+ expanded = expanded.replace(/%([^%]+)%/g, (_, key) => {
129
+ const value = process.env[key];
130
+ if (!value) {
131
+ throw new Error(`Environment variable %${key}% is not set for target expansion.`);
132
+ }
133
+ return value;
134
+ });
135
+ expanded = expanded.replace(/\$HOME|\$\{HOME\}/g, userProfile);
136
+ } else {
137
+ if (expanded.startsWith("~")) {
138
+ expanded = `${homePath}${expanded.slice(1)}`;
139
+ }
140
+ expanded = expanded.replace(/\$HOME|\$\{HOME\}/g, homePath);
141
+ }
142
+
143
+ return path.resolve(expanded);
144
+ }
145
+
146
+ export function normalizeRepoPath(rawPath, label) {
147
+ if (typeof rawPath !== "string" || rawPath.trim().length === 0) {
148
+ throw new Error(`Invalid repository path for ${label}.`);
149
+ }
150
+ const normalized = rawPath.trim().replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
151
+ if (normalized.length === 0) {
152
+ throw new Error(`Invalid repository path for ${label}.`);
153
+ }
154
+ const parts = normalized.split("/");
155
+ for (const part of parts) {
156
+ if (part === "." || part === ".." || part.length === 0) {
157
+ throw new Error(`Invalid repository path '${rawPath}' for ${label}.`);
158
+ }
159
+ }
160
+ return parts.join("/");
161
+ }
162
+
163
+ export function normalizeDestPrefix(rawPrefix, fallbackPrefix, label) {
164
+ const base = typeof rawPrefix === "string" && rawPrefix.trim().length > 0 ? rawPrefix : fallbackPrefix;
165
+ return normalizeRepoPath(base, `${label}.destPrefix`);
166
+ }
167
+
168
+ function normalizeSimpleYamlValue(value) {
169
+ const trimmed = value.trim();
170
+ if (
171
+ (trimmed.startsWith("\"") && trimmed.endsWith("\"")) ||
172
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
173
+ ) {
174
+ return trimmed.slice(1, -1).trim();
175
+ }
176
+ return trimmed;
177
+ }
178
+
179
+ export function parseSimpleFrontmatter(markdown) {
180
+ const lines = markdown.split(/\r?\n/);
181
+ if (lines.length === 0 || lines[0].trim() !== "---") {
182
+ return {
183
+ frontmatter: {},
184
+ bodyLines: lines
185
+ };
186
+ }
187
+
188
+ const frontmatter = {};
189
+ let endIndex = -1;
190
+ for (let index = 1; index < lines.length; index += 1) {
191
+ const line = lines[index];
192
+ if (line.trim() === "---") {
193
+ endIndex = index;
194
+ break;
195
+ }
196
+ const match = line.match(/^\s*([A-Za-z0-9_-]+)\s*:\s*(.+?)\s*$/);
197
+ if (match) {
198
+ frontmatter[match[1].toLowerCase()] = normalizeSimpleYamlValue(match[2]);
199
+ }
200
+ }
201
+
202
+ if (endIndex === -1) {
203
+ return {
204
+ frontmatter: {},
205
+ bodyLines: lines
206
+ };
207
+ }
208
+
209
+ return {
210
+ frontmatter,
211
+ bodyLines: lines.slice(endIndex + 1)
212
+ };
213
+ }
214
+
215
+ export function extractFirstMarkdownHeading(lines) {
216
+ for (const rawLine of lines) {
217
+ const trimmed = rawLine.trim();
218
+ const match = trimmed.match(/^#{1,6}\s+(.+)$/);
219
+ if (match) {
220
+ return match[1].trim();
221
+ }
222
+ }
223
+ return null;
224
+ }
225
+
226
+ export function extractSkillTitleFromMarkdown(markdown, fallbackTitle) {
227
+ const { frontmatter, bodyLines } = parseSimpleFrontmatter(markdown);
228
+ const frontmatterTitle = frontmatter.title;
229
+ if (typeof frontmatterTitle === "string" && frontmatterTitle.trim().length > 0) {
230
+ return frontmatterTitle.trim();
231
+ }
232
+ const heading = extractFirstMarkdownHeading(bodyLines);
233
+ if (heading && heading.length > 0) {
234
+ return heading;
235
+ }
236
+ return fallbackTitle;
237
+ }
238
+
239
+ export async function extractSkillSummary(skillFilePath) {
240
+ if (!(await fs.pathExists(skillFilePath))) {
241
+ return "No summary provided.";
242
+ }
243
+
244
+ const raw = await fs.readFile(skillFilePath, "utf8");
245
+ const { frontmatter, bodyLines } = parseSimpleFrontmatter(raw);
246
+ const frontmatterSummary = frontmatter.summary ?? frontmatter.description;
247
+ if (typeof frontmatterSummary === "string" && frontmatterSummary.trim().length > 0) {
248
+ return frontmatterSummary.trim();
249
+ }
250
+
251
+ const paragraph = [];
252
+ for (const line of bodyLines) {
253
+ const trimmed = line.trim();
254
+ if (!trimmed) {
255
+ if (paragraph.length > 0) {
256
+ break;
257
+ }
258
+ continue;
259
+ }
260
+ if (trimmed.startsWith("#")) {
261
+ if (paragraph.length > 0) {
262
+ break;
263
+ }
264
+ continue;
265
+ }
266
+ paragraph.push(trimmed);
267
+ }
268
+
269
+ if (paragraph.length > 0) {
270
+ return paragraph.join(" ");
271
+ }
272
+ return "No summary provided.";
273
+ }
274
+
275
+ export async function readJsonFile(filePath) {
276
+ try {
277
+ return await fs.readJson(filePath);
278
+ } catch (error) {
279
+ throw new Error(`Failed to read JSON file ${filePath}: ${error.message}`);
280
+ }
281
+ }
282
+
283
+ export async function writeJsonFile(filePath, value) {
284
+ await fs.ensureDir(path.dirname(filePath));
285
+ const serialized = `${JSON.stringify(value, null, 2)}\n`;
286
+ await fs.writeFile(filePath, serialized, "utf8");
287
+ }
288
+
289
+ export async function writeJsonFileIfMissing(filePath, value) {
290
+ if (await fs.pathExists(filePath)) {
291
+ return false;
292
+ }
293
+ await writeJsonFile(filePath, value);
294
+ return true;
295
+ }
296
+
297
+ async function getValidator(schemaPath) {
298
+ if (validatorCache.has(schemaPath)) {
299
+ return validatorCache.get(schemaPath);
300
+ }
301
+ const schema = await readJsonFile(schemaPath);
302
+ const validate = ajv.compile(schema);
303
+ validatorCache.set(schemaPath, validate);
304
+ return validate;
305
+ }
306
+
307
+ function formatAjvErrors(errors = []) {
308
+ return errors
309
+ .map((item) => {
310
+ const location = item.instancePath && item.instancePath.length > 0 ? item.instancePath : "$";
311
+ return `${location}: ${item.message}`;
312
+ })
313
+ .join("; ");
314
+ }
315
+
316
+ export async function assertObjectMatchesSchema(value, schemaPath, label) {
317
+ const validate = await getValidator(schemaPath);
318
+ const valid = validate(value);
319
+ if (!valid) {
320
+ throw new Error(`Schema validation failed for ${label}: ${formatAjvErrors(validate.errors)}`);
321
+ }
322
+ }
323
+
324
+ export async function assertJsonFileMatchesSchema(jsonPath, schemaPath) {
325
+ const value = await readJsonFile(jsonPath);
326
+ await assertObjectMatchesSchema(value, schemaPath, jsonPath);
327
+ return value;
328
+ }
329
+
330
+ export async function existsOrLink(targetPath) {
331
+ try {
332
+ await fs.lstat(targetPath);
333
+ return true;
334
+ } catch {
335
+ return false;
336
+ }
337
+ }
338
+
339
+ export async function fileSha256(filePath) {
340
+ const content = await fs.readFile(filePath);
341
+ return crypto.createHash("sha256").update(content).digest("hex");
342
+ }
343
+
344
+ export async function resolveLinkTarget(targetPath) {
345
+ let stats;
346
+ try {
347
+ stats = await fs.lstat(targetPath);
348
+ } catch {
349
+ return null;
350
+ }
351
+
352
+ if (!stats.isSymbolicLink()) {
353
+ return null;
354
+ }
355
+
356
+ let linkTarget = await fs.readlink(targetPath);
357
+ if (process.platform === "win32") {
358
+ if (linkTarget.startsWith("\\\\?\\")) {
359
+ linkTarget = linkTarget.slice(4);
360
+ }
361
+ if (linkTarget.startsWith("\\??\\")) {
362
+ linkTarget = linkTarget.slice(4);
363
+ }
364
+ }
365
+ if (!path.isAbsolute(linkTarget)) {
366
+ linkTarget = path.resolve(path.dirname(targetPath), linkTarget);
367
+ }
368
+ return path.resolve(linkTarget);
369
+ }
370
+
371
+ export async function bindingMatches(binding) {
372
+ if (!(await existsOrLink(binding.targetPath))) {
373
+ return false;
374
+ }
375
+
376
+ if (binding.method === "symlink" || binding.method === "junction") {
377
+ const target = await resolveLinkTarget(binding.targetPath);
378
+ return Boolean(target) && pathsEqual(target, binding.sourcePath);
379
+ }
380
+
381
+ if (binding.method === "hardlink") {
382
+ const targetHash = await fileSha256(binding.targetPath);
383
+ if (binding.hash) {
384
+ return targetHash === binding.hash;
385
+ }
386
+ if (!(await fs.pathExists(binding.sourcePath))) {
387
+ return true;
388
+ }
389
+ const sourceHash = await fileSha256(binding.sourcePath);
390
+ return targetHash === sourceHash;
391
+ }
392
+
393
+ if (binding.method === "copy") {
394
+ if (binding.kind === "file") {
395
+ if (binding.hash) {
396
+ const targetHash = await fileSha256(binding.targetPath);
397
+ return targetHash === binding.hash;
398
+ }
399
+ if (await fs.pathExists(binding.sourcePath)) {
400
+ const left = await fileSha256(binding.targetPath);
401
+ const right = await fileSha256(binding.sourcePath);
402
+ return left === right;
403
+ }
404
+ }
405
+ return true;
406
+ }
407
+
408
+ return false;
409
+ }
410
+
411
+ export async function createDirectoryBinding(sourcePath, targetPath, osName) {
412
+ await fs.ensureDir(path.dirname(targetPath));
413
+ if (osName === "windows") {
414
+ try {
415
+ await fs.symlink(sourcePath, targetPath, "junction");
416
+ return "junction";
417
+ } catch {
418
+ await fs.symlink(sourcePath, targetPath, "dir");
419
+ return "symlink";
420
+ }
421
+ }
422
+ await fs.symlink(sourcePath, targetPath, "dir");
423
+ return "symlink";
424
+ }
425
+
426
+ export async function createFileBinding(sourcePath, targetPath, osName) {
427
+ await fs.ensureDir(path.dirname(targetPath));
428
+ if (osName === "windows") {
429
+ try {
430
+ await fs.link(sourcePath, targetPath);
431
+ return { method: "hardlink", hash: await fileSha256(sourcePath) };
432
+ } catch {
433
+ await fs.copyFile(sourcePath, targetPath);
434
+ return { method: "copy", hash: await fileSha256(targetPath) };
435
+ }
436
+ }
437
+ await fs.symlink(sourcePath, targetPath, "file");
438
+ return { method: "symlink", hash: null };
439
+ }
440
+
441
+ export function collisionKey(relativePath) {
442
+ return process.platform === "win32" ? relativePath.toLowerCase() : relativePath;
443
+ }
444
+
445
+ export function toFileSystemRelativePath(relativePosixPath) {
446
+ return relativePosixPath.split("/").join(path.sep);
447
+ }
@@ -0,0 +1,56 @@
1
+ import { logInfo } from "./core.js";
2
+ import { collectAgentInventories } from "./agents.js";
3
+
4
+ function redactPathDetails(message) {
5
+ return String(message ?? "")
6
+ .replace(/[A-Za-z]:\\[^\s'"]+/g, "<path>")
7
+ .replace(/~\/[^\s'"]+/g, "<path>")
8
+ .replace(/\/(?:[^/\s]+\/)+[^/\s]+/g, "<path>")
9
+ .replace(/\b[\w.-]+\.(json|toml|md)\b/g, "<file>");
10
+ }
11
+
12
+ function toPublicParseErrors(parseErrors) {
13
+ return parseErrors.map((issue) => ({
14
+ kind: issue.kind,
15
+ message: redactPathDetails(issue.message)
16
+ }));
17
+ }
18
+
19
+ export async function cmdDetect({ format = "text", agents } = {}) {
20
+ const inventory = await collectAgentInventories({ agents });
21
+ const detailedRows = inventory.agents.map((agent) => ({
22
+ tool: agent.tool,
23
+ support: agent.support,
24
+ canOverride: agent.canOverride,
25
+ hasSkills: agent.hasSkillsPath,
26
+ hasMcp: agent.hasMcpPath,
27
+ installed: agent.installed,
28
+ parseErrors: toPublicParseErrors(agent.parseErrors)
29
+ }));
30
+
31
+ if (format === "json") {
32
+ process.stdout.write(
33
+ `${JSON.stringify({ os: inventory.os, tools: detailedRows }, null, 2)}\n`
34
+ );
35
+ return;
36
+ }
37
+
38
+ logInfo(`Detected host OS: ${inventory.os}`);
39
+ process.stdout.write("\n");
40
+ for (const row of detailedRows) {
41
+ process.stdout.write(`${row.tool}\n`);
42
+ process.stdout.write(` status : ${row.installed ? "detected" : "not detected"}\n`);
43
+ process.stdout.write(` support : ${row.support}\n`);
44
+ process.stdout.write(` skills found: ${row.hasSkills ? "yes" : "no"}\n`);
45
+ process.stdout.write(` mcp found : ${row.hasMcp ? "yes" : "no"}\n`);
46
+ if (row.parseErrors.length === 0) {
47
+ process.stdout.write(" parse errors: none\n");
48
+ } else {
49
+ process.stdout.write(` parse errors: ${row.parseErrors.length}\n`);
50
+ for (const issue of row.parseErrors) {
51
+ process.stdout.write(` [${issue.kind}] ${issue.message}\n`);
52
+ }
53
+ }
54
+ process.stdout.write(` canOverride : ${row.canOverride}\n\n`);
55
+ }
56
+ }