@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,165 @@
1
+ import fs from "fs-extra";
2
+ import path from "node:path";
3
+ import {
4
+ SCHEMAS,
5
+ assertObjectMatchesSchema,
6
+ collisionKey,
7
+ toFileSystemRelativePath,
8
+ writeJsonFile
9
+ } from "./core.js";
10
+ import { checkoutCommit, ensureCommitAvailable, getLockKey } from "./upstreams.js";
11
+
12
+ export async function collectLocalSkillEntries(packRoot) {
13
+ const skillsRoot = path.join(packRoot, "skills");
14
+ if (!(await fs.pathExists(skillsRoot))) {
15
+ return [];
16
+ }
17
+
18
+ const entries = await fs.readdir(skillsRoot, { withFileTypes: true });
19
+ const dirs = entries
20
+ .filter((entry) => entry.isDirectory())
21
+ .map((entry) => entry.name)
22
+ .sort((left, right) => left.localeCompare(right));
23
+
24
+ return dirs.map((skillName) => ({
25
+ sourceType: "local",
26
+ sourcePath: path.join(skillsRoot, skillName),
27
+ destRelative: skillName,
28
+ label: `local:${skillName}`
29
+ }));
30
+ }
31
+
32
+ export function collectImportedSkillEntries(skillImports, resolvedReferences) {
33
+ return skillImports.map((item) => {
34
+ const key = getLockKey(item.upstreamId, item.ref);
35
+ const resolved = resolvedReferences.get(key);
36
+ if (!resolved) {
37
+ throw new Error(`Internal error: unresolved upstream reference for ${item.upstreamId}@${item.ref}`);
38
+ }
39
+ return {
40
+ sourceType: "upstream",
41
+ upstreamId: item.upstreamId,
42
+ ref: item.ref,
43
+ commit: resolved.commit,
44
+ repoPath: resolved.repoPath,
45
+ sourceRepoPath: item.repoPath,
46
+ destRelative: item.destRelative,
47
+ label: item.label
48
+ };
49
+ });
50
+ }
51
+
52
+ function assertNoSkillCollisions(entries) {
53
+ const seen = new Map();
54
+ for (const entry of entries) {
55
+ const key = collisionKey(entry.destRelative);
56
+ if (seen.has(key)) {
57
+ const previous = seen.get(key);
58
+ throw new Error(
59
+ `Skill destination collision detected at '${entry.destRelative}'.` +
60
+ ` First: ${previous.label}; Second: ${entry.label}.`
61
+ );
62
+ }
63
+ seen.set(key, entry);
64
+ }
65
+ }
66
+
67
+ async function materializeBundleSkills(entries, destinationRoot) {
68
+ const sortedEntries = [...entries].sort((left, right) => left.destRelative.localeCompare(right.destRelative));
69
+ const checkoutTracker = new Map();
70
+
71
+ await fs.ensureDir(destinationRoot);
72
+ for (const entry of sortedEntries) {
73
+ const destination = path.join(destinationRoot, toFileSystemRelativePath(entry.destRelative));
74
+ if (await fs.pathExists(destination)) {
75
+ throw new Error(`Destination already exists while materializing skills: ${destination}`);
76
+ }
77
+ await fs.ensureDir(path.dirname(destination));
78
+
79
+ if (entry.sourceType === "local") {
80
+ await fs.copy(entry.sourcePath, destination);
81
+ continue;
82
+ }
83
+
84
+ await ensureCommitAvailable(entry.repoPath, entry.commit);
85
+ await checkoutCommit(entry.repoPath, entry.commit, checkoutTracker);
86
+ const sourcePath = path.join(entry.repoPath, toFileSystemRelativePath(entry.sourceRepoPath));
87
+ const stats = await fs.stat(sourcePath).catch(() => null);
88
+ if (!stats || !stats.isDirectory()) {
89
+ throw new Error(
90
+ `Imported source path '${entry.sourceRepoPath}' from upstream '${entry.upstreamId}' is not a directory at commit ${entry.commit}.`
91
+ );
92
+ }
93
+ await fs.copy(sourcePath, destination);
94
+ }
95
+ }
96
+
97
+ function buildBundleImports(skillImports, resolvedReferences) {
98
+ const imports = [];
99
+ for (const item of skillImports) {
100
+ const key = getLockKey(item.upstreamId, item.ref);
101
+ const resolved = resolvedReferences.get(key);
102
+ if (!resolved) {
103
+ throw new Error(`Internal error: unresolved upstream reference for ${item.upstreamId}@${item.ref}.`);
104
+ }
105
+ imports.push({
106
+ upstream: item.upstreamId,
107
+ ref: item.ref,
108
+ commit: resolved.commit,
109
+ path: item.repoPath,
110
+ destPrefix: path.posix.dirname(item.destRelative)
111
+ });
112
+ }
113
+
114
+ imports.sort((left, right) => {
115
+ const leftKey = `${left.upstream}::${left.ref}::${left.path}::${left.destPrefix}`;
116
+ const rightKey = `${right.upstream}::${right.ref}::${right.path}::${right.destPrefix}`;
117
+ return leftKey.localeCompare(rightKey);
118
+ });
119
+ return imports;
120
+ }
121
+
122
+ export async function buildBundle({
123
+ profile,
124
+ packRoot,
125
+ skillImports,
126
+ resolvedReferences,
127
+ normalizedMcp,
128
+ runtimeInternalRoot
129
+ }) {
130
+ const localSkillEntries = await collectLocalSkillEntries(packRoot);
131
+ const importedSkillEntries = collectImportedSkillEntries(skillImports, resolvedReferences);
132
+ const skillEntries = [...localSkillEntries, ...importedSkillEntries];
133
+ assertNoSkillCollisions(skillEntries);
134
+
135
+ const bundleRoot = path.join(runtimeInternalRoot, "common");
136
+ const bundleSkillsPath = path.join(bundleRoot, "skills");
137
+ const bundleMcpPath = path.join(bundleRoot, "mcp.json");
138
+ const bundleMetadataPath = path.join(bundleRoot, "bundle.json");
139
+
140
+ await fs.ensureDir(bundleRoot);
141
+ await materializeBundleSkills(skillEntries, bundleSkillsPath);
142
+ await writeJsonFile(bundleMcpPath, normalizedMcp);
143
+
144
+ const bundleDocument = {
145
+ schemaVersion: 1,
146
+ profile: profile.name,
147
+ generatedAt: new Date().toISOString(),
148
+ sources: {
149
+ packPath: packRoot,
150
+ imports: buildBundleImports(skillImports, resolvedReferences)
151
+ }
152
+ };
153
+ await assertObjectMatchesSchema(bundleDocument, SCHEMAS.bundle, "bundle metadata");
154
+ await writeJsonFile(bundleMetadataPath, bundleDocument);
155
+
156
+ return {
157
+ bundleRoot,
158
+ bundleSkillsPath,
159
+ bundleMcpPath,
160
+ bundleMetadataPath,
161
+ localSkillEntries,
162
+ importedSkillEntries,
163
+ skillEntries
164
+ };
165
+ }
@@ -0,0 +1,324 @@
1
+ import fs from "fs-extra";
2
+ import merge from "deepmerge";
3
+ import path from "node:path";
4
+ import {
5
+ ASSETS_ROOT,
6
+ CONFIG_PATH,
7
+ LOCAL_OVERRIDES_ROOT,
8
+ SCHEMAS,
9
+ assertJsonFileMatchesSchema,
10
+ assertObjectMatchesSchema,
11
+ getTargetManifestPath,
12
+ logInfo,
13
+ logWarn,
14
+ toAbsolutePath,
15
+ writeJsonFile
16
+ } from "./core.js";
17
+
18
+ function normalizeProfileName(profileName) {
19
+ if (typeof profileName !== "string") {
20
+ throw new Error("Profile name must be a non-empty string.");
21
+ }
22
+ const normalized = profileName.trim();
23
+ if (normalized.length === 0) {
24
+ throw new Error("Profile name must be a non-empty string.");
25
+ }
26
+ return normalized;
27
+ }
28
+
29
+ async function scaffoldProfileFiles(profileName) {
30
+ const normalizedName = normalizeProfileName(profileName);
31
+ const profilesDir = path.join(LOCAL_OVERRIDES_ROOT, "profiles");
32
+ const packRoot = path.join(LOCAL_OVERRIDES_ROOT, "packs", normalizedName);
33
+ const mcpDir = path.join(packRoot, "mcp");
34
+
35
+ await fs.ensureDir(profilesDir);
36
+ await fs.ensureDir(mcpDir);
37
+
38
+ const toCreate = [
39
+ {
40
+ path: path.join(profilesDir, `${normalizedName}.json`),
41
+ value: { name: normalizedName, packPath: `workspace/packs/${normalizedName}` }
42
+ },
43
+ {
44
+ path: path.join(packRoot, "pack.json"),
45
+ value: { name: normalizedName, version: "0.0.0", description: "", maintainer: "", tags: [] }
46
+ },
47
+ { path: path.join(packRoot, "sources.json"), value: { imports: [] } },
48
+ { path: path.join(mcpDir, "servers.json"), value: { servers: {} } }
49
+ ];
50
+
51
+ let created = 0;
52
+ for (const file of toCreate) {
53
+ if (!(await fs.pathExists(file.path))) {
54
+ await writeJsonFile(file.path, file.value);
55
+ created += 1;
56
+ }
57
+ }
58
+ return { created, normalizedName };
59
+ }
60
+
61
+ export async function resolveProfile(profileName) {
62
+ const localPath = path.join(LOCAL_OVERRIDES_ROOT, "profiles", `${profileName}.json`);
63
+ const seedPath = path.join(ASSETS_ROOT, "seed", "profiles", `${profileName}.json`);
64
+
65
+ let profilePath = null;
66
+ if (await fs.pathExists(localPath)) {
67
+ profilePath = localPath;
68
+ } else if (await fs.pathExists(seedPath)) {
69
+ profilePath = seedPath;
70
+ }
71
+ if (!profilePath) {
72
+ throw new Error(`Profile '${profileName}' not found. Run 'skills-sync ls' to see available profiles.`);
73
+ }
74
+
75
+ const profile = await assertJsonFileMatchesSchema(profilePath, SCHEMAS.profile);
76
+ return { profilePath, profile };
77
+ }
78
+
79
+ export async function resolvePack(profile) {
80
+ const candidate = toAbsolutePath(profile.packPath);
81
+ if (await fs.pathExists(candidate)) {
82
+ return candidate;
83
+ }
84
+ if (profile.packPath.startsWith("local-overrides/")) {
85
+ const migrated = toAbsolutePath(profile.packPath.replace(/^local-overrides\//, "workspace/"));
86
+ if (await fs.pathExists(migrated)) {
87
+ return migrated;
88
+ }
89
+ }
90
+ if (profile.packPath.startsWith("workspace/")) {
91
+ const legacy = toAbsolutePath(profile.packPath.replace(/^workspace\//, "local-overrides/"));
92
+ if (await fs.pathExists(legacy)) {
93
+ return legacy;
94
+ }
95
+ }
96
+ const packName = path.basename(profile.packPath);
97
+ const fallback = path.join(ASSETS_ROOT, "seed", "packs", packName);
98
+ if (await fs.pathExists(fallback)) {
99
+ return fallback;
100
+ }
101
+ throw new Error(`Pack for profile '${profile.name ?? "unknown"}' was not found.`);
102
+ }
103
+
104
+ export async function loadPackSources(packRoot) {
105
+ const sourcesPath = path.join(packRoot, "sources.json");
106
+ if (!(await fs.pathExists(sourcesPath))) {
107
+ return {
108
+ path: null,
109
+ sources: {
110
+ imports: []
111
+ }
112
+ };
113
+ }
114
+
115
+ const sources = await assertJsonFileMatchesSchema(sourcesPath, SCHEMAS.packSources);
116
+ return {
117
+ path: sourcesPath,
118
+ sources: {
119
+ imports: Array.isArray(sources.imports) ? sources.imports : []
120
+ }
121
+ };
122
+ }
123
+
124
+ function normalizeMcpEnvMap(rawEnv) {
125
+ if (!rawEnv || typeof rawEnv !== "object" || Array.isArray(rawEnv)) {
126
+ return {};
127
+ }
128
+ const normalized = {};
129
+ const keys = Object.keys(rawEnv).sort((left, right) => left.localeCompare(right));
130
+ for (const key of keys) {
131
+ if (key.length === 0) {
132
+ continue;
133
+ }
134
+ normalized[key] = String(rawEnv[key]);
135
+ }
136
+ return normalized;
137
+ }
138
+
139
+ export function normalizeMcpManifest(serversManifest) {
140
+ const servers = serversManifest.servers ?? {};
141
+ const sortedNames = Object.keys(servers).sort((left, right) => left.localeCompare(right));
142
+ const normalizedServers = {};
143
+ for (const name of sortedNames) {
144
+ const server = servers[name] ?? {};
145
+ if (typeof server.url === "string" && server.url.trim().length > 0) {
146
+ normalizedServers[name] = {
147
+ url: server.url.trim()
148
+ };
149
+ continue;
150
+ }
151
+ const normalizedServer = {
152
+ transport: "stdio",
153
+ command: server.command,
154
+ args: Array.isArray(server.args) ? server.args : []
155
+ };
156
+ const env = normalizeMcpEnvMap(server.env);
157
+ if (Object.keys(env).length > 0) {
158
+ normalizedServer.env = env;
159
+ }
160
+ normalizedServers[name] = normalizedServer;
161
+ }
162
+ return { mcpServers: normalizedServers };
163
+ }
164
+
165
+ export async function readDefaultProfile() {
166
+ if (!(await fs.pathExists(CONFIG_PATH))) {
167
+ return null;
168
+ }
169
+ const config = await assertJsonFileMatchesSchema(CONFIG_PATH, SCHEMAS.config);
170
+ const val = config.defaultProfile;
171
+ return typeof val === "string" && val.trim().length > 0 ? val.trim() : null;
172
+ }
173
+
174
+ async function collectProfilesFromDir(dirPath, source, seen, profiles) {
175
+ if (!(await fs.pathExists(dirPath))) {
176
+ return;
177
+ }
178
+ const entries = await fs.readdir(dirPath);
179
+ for (const entry of entries) {
180
+ if (!entry.endsWith(".json")) {
181
+ continue;
182
+ }
183
+ const name = entry.slice(0, -5);
184
+ if (seen.has(name)) {
185
+ continue;
186
+ }
187
+ seen.add(name);
188
+ profiles.push({
189
+ name,
190
+ source,
191
+ path: path.join(dirPath, entry)
192
+ });
193
+ }
194
+ }
195
+
196
+ export async function listAvailableProfiles() {
197
+ const localDir = path.join(LOCAL_OVERRIDES_ROOT, "profiles");
198
+ const seedDir = path.join(ASSETS_ROOT, "seed", "profiles");
199
+ const seen = new Set();
200
+ const profiles = [];
201
+
202
+ await collectProfilesFromDir(localDir, "local", seen, profiles);
203
+ await collectProfilesFromDir(seedDir, "seed", seen, profiles);
204
+
205
+ return profiles.sort((left, right) => left.name.localeCompare(right.name));
206
+ }
207
+
208
+ export async function writeDefaultProfile(profileName) {
209
+ const normalizedName = normalizeProfileName(profileName);
210
+ const localProfilePath = path.join(LOCAL_OVERRIDES_ROOT, "profiles", `${normalizedName}.json`);
211
+ const seedProfilePath = path.join(ASSETS_ROOT, "seed", "profiles", `${normalizedName}.json`);
212
+ const hasLocalProfile = await fs.pathExists(localProfilePath);
213
+ const hasSeedProfile = await fs.pathExists(seedProfilePath);
214
+
215
+ if (!hasLocalProfile && !hasSeedProfile) {
216
+ const { created } = await scaffoldProfileFiles(normalizedName);
217
+ if (created > 0) {
218
+ logInfo(`Profile '${normalizedName}' did not exist. Created empty scaffold.`);
219
+ }
220
+ }
221
+
222
+ let existing = {};
223
+ if (await fs.pathExists(CONFIG_PATH)) {
224
+ existing = await fs.readJson(CONFIG_PATH);
225
+ }
226
+ await writeJsonFile(CONFIG_PATH, { ...existing, defaultProfile: normalizedName });
227
+ logInfo(`Default profile set to '${normalizedName}'.`);
228
+ }
229
+
230
+ export async function cmdCurrentProfile() {
231
+ const current = await readDefaultProfile();
232
+ if (!current) {
233
+ process.stdout.write("No default profile set. Run: skills-sync use <name>\n");
234
+ return;
235
+ }
236
+ process.stdout.write(`${current}\n`);
237
+ }
238
+
239
+ export async function cmdListProfiles({ format = "text" } = {}) {
240
+ const current = await readDefaultProfile();
241
+ const profiles = await listAvailableProfiles();
242
+
243
+ if (profiles.length === 0) {
244
+ if (format === "json") {
245
+ process.stdout.write(`${JSON.stringify({ current, profiles: [] }, null, 2)}\n`);
246
+ } else {
247
+ process.stdout.write("No profiles found.\n");
248
+ }
249
+ return;
250
+ }
251
+
252
+ if (format === "json") {
253
+ process.stdout.write(
254
+ `${JSON.stringify(
255
+ {
256
+ current,
257
+ profiles: profiles.map((item) => ({
258
+ name: item.name,
259
+ source: item.source
260
+ }))
261
+ },
262
+ null,
263
+ 2
264
+ )}\n`
265
+ );
266
+ return;
267
+ }
268
+
269
+ for (const { name, source } of profiles) {
270
+ const marker = name === current ? "->" : " ";
271
+ process.stdout.write(`${marker} ${name} (${source})\n`);
272
+ }
273
+ }
274
+
275
+ export async function cmdNewProfile(name) {
276
+ const { created, normalizedName } = await scaffoldProfileFiles(name);
277
+
278
+ if (created === 0) {
279
+ logInfo(`Profile '${normalizedName}' already exists (no files overwritten).`);
280
+ return;
281
+ }
282
+ logInfo(`Created profile '${normalizedName}'.`);
283
+ process.stdout.write(`\nNext: skills-sync use ${normalizedName}\n`);
284
+ }
285
+
286
+ export async function cmdRemoveProfile(name) {
287
+ const profilePath = path.join(LOCAL_OVERRIDES_ROOT, "profiles", `${name}.json`);
288
+ if (!(await fs.pathExists(profilePath))) {
289
+ throw new Error(`Profile '${name}' not found.`);
290
+ }
291
+
292
+ await fs.rm(profilePath);
293
+ logInfo(`Removed profile '${name}'.`);
294
+
295
+ if (await fs.pathExists(CONFIG_PATH)) {
296
+ const config = await fs.readJson(CONFIG_PATH);
297
+ if (config.defaultProfile === name) {
298
+ delete config.defaultProfile;
299
+ await writeJsonFile(CONFIG_PATH, config);
300
+ logWarn(`Default profile '${name}' was cleared.`);
301
+ }
302
+ }
303
+
304
+ const packPath = path.join(LOCAL_OVERRIDES_ROOT, "packs", name);
305
+ if (await fs.pathExists(packPath)) {
306
+ logWarn(`Pack for '${name}' still exists. Remove it manually if you no longer need it.`);
307
+ }
308
+ }
309
+
310
+ export async function loadEffectiveTargets(osName) {
311
+ const basePath = getTargetManifestPath(osName);
312
+ const baseTargets = await assertJsonFileMatchesSchema(basePath, SCHEMAS.targets);
313
+
314
+ const overridePath = path.join(LOCAL_OVERRIDES_ROOT, "manifests", "targets.override.json");
315
+ let effectiveTargets = baseTargets;
316
+ if (await fs.pathExists(overridePath)) {
317
+ const overrideTargets = await fs.readJson(overridePath);
318
+ effectiveTargets = merge(baseTargets, overrideTargets, {
319
+ arrayMerge: (_destinationArray, sourceArray) => sourceArray
320
+ });
321
+ }
322
+ await assertObjectMatchesSchema(effectiveTargets, SCHEMAS.targets, "effective target mapping");
323
+ return effectiveTargets;
324
+ }