@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,510 @@
1
+ import fs from "fs-extra";
2
+ import path from "node:path";
3
+ import {
4
+ LOCAL_OVERRIDES_ROOT,
5
+ MANAGED_BY,
6
+ RUNTIME_INTERNAL_ROOT,
7
+ bindingMatches,
8
+ createDirectoryBinding,
9
+ detectOsName,
10
+ existsOrLink,
11
+ expandTargetPath,
12
+ logInfo,
13
+ logWarn,
14
+ pathsEqual,
15
+ resolveLinkTarget,
16
+ writeJsonFile
17
+ } from "./core.js";
18
+ import { loadEffectiveTargets } from "./config.js";
19
+ import { applyManagedMcpConfig, removeManagedMcpConfig } from "./mcp-config.js";
20
+
21
+ function redactPathDetails(message) {
22
+ return String(message ?? "")
23
+ .replace(/[A-Za-z]:\\[^\s'"]+/g, "<path>")
24
+ .replace(/~\/[^\s'"]+/g, "<path>")
25
+ .replace(/\/(?:[^/\s]+\/)+[^/\s]+/g, "<path>")
26
+ .replace(/\b[\w.-]+\.(json|toml|md)\b/g, "<file>");
27
+ }
28
+
29
+ export async function getStatePath() {
30
+ const stateDir = path.join(LOCAL_OVERRIDES_ROOT, "state");
31
+ await fs.ensureDir(stateDir);
32
+ return path.join(stateDir, "active-profile.json");
33
+ }
34
+
35
+ function getDirectoryBindingSpecs(effectiveTargets, runtimeInternalRoot) {
36
+ const specs = [
37
+ {
38
+ tool: "codex",
39
+ sourcePath: path.join(runtimeInternalRoot, ".codex", "skills"),
40
+ targetRawPath: effectiveTargets.codex.skillsDir
41
+ },
42
+ {
43
+ tool: "claude",
44
+ sourcePath: path.join(runtimeInternalRoot, ".claude", "skills"),
45
+ targetRawPath: effectiveTargets.claude.skillsDir
46
+ },
47
+ {
48
+ tool: "cursor",
49
+ sourcePath: path.join(runtimeInternalRoot, ".cursor", "skills"),
50
+ targetRawPath: effectiveTargets.cursor.skillsDir
51
+ },
52
+ {
53
+ tool: "copilot",
54
+ sourcePath: path.join(runtimeInternalRoot, ".copilot", "skills"),
55
+ targetRawPath: effectiveTargets.copilot.skillsDir
56
+ },
57
+ {
58
+ tool: "gemini",
59
+ sourcePath: path.join(runtimeInternalRoot, ".gemini", "skills"),
60
+ targetRawPath: effectiveTargets.gemini.skillsDir
61
+ }
62
+ ];
63
+ return specs.filter((spec) => typeof spec.targetRawPath === "string" && spec.targetRawPath.trim().length > 0);
64
+ }
65
+
66
+ function getConfigSpecs(effectiveTargets, runtimeInternalRoot) {
67
+ return [
68
+ {
69
+ tool: "codex",
70
+ sourcePath: path.join(runtimeInternalRoot, ".codex", "config.toml"),
71
+ targetRawPath: effectiveTargets.codex.mcpConfig
72
+ },
73
+ {
74
+ tool: "claude",
75
+ sourcePath: path.join(runtimeInternalRoot, ".claude", "mcp.json"),
76
+ targetRawPath: effectiveTargets.claude.mcpConfig
77
+ },
78
+ {
79
+ tool: "cursor",
80
+ sourcePath: path.join(runtimeInternalRoot, ".cursor", "mcp.json"),
81
+ targetRawPath: effectiveTargets.cursor.mcpConfig
82
+ },
83
+ {
84
+ tool: "copilot",
85
+ sourcePath: path.join(runtimeInternalRoot, ".copilot", "mcp-config.json"),
86
+ targetRawPath: effectiveTargets.copilot.mcpConfig
87
+ },
88
+ {
89
+ tool: "gemini",
90
+ sourcePath: path.join(runtimeInternalRoot, ".gemini", "settings.json"),
91
+ targetRawPath: effectiveTargets.gemini.mcpConfig
92
+ }
93
+ ];
94
+ }
95
+
96
+ function formatUnmanagedPathError(targetPath, profileName) {
97
+ void targetPath;
98
+ return (
99
+ "Refusing to replace an unmanaged existing target.\n" +
100
+ "Remediation:\n" +
101
+ " - Move or remove the existing target manually.\n" +
102
+ ` - Re-run apply for profile '${profileName}'.`
103
+ );
104
+ }
105
+
106
+ async function isAdoptableDirectoryBinding(sourcePath, targetPath) {
107
+ const linkTarget = await resolveLinkTarget(targetPath);
108
+ return Boolean(linkTarget) && pathsEqual(linkTarget, sourcePath);
109
+ }
110
+
111
+ async function bindNestedDirectoryEntries({
112
+ tool,
113
+ sourcePath,
114
+ targetPath,
115
+ osName,
116
+ profileName,
117
+ bindings,
118
+ createdTargets,
119
+ dryRun = false,
120
+ plannedActions = null
121
+ }) {
122
+ if (!dryRun) {
123
+ await fs.ensureDir(targetPath);
124
+ }
125
+ const entries = await fs.readdir(sourcePath, { withFileTypes: true });
126
+ const directories = entries.filter((entry) => entry.isDirectory()).sort((left, right) => left.name.localeCompare(right.name));
127
+
128
+ for (const directory of directories) {
129
+ const sourceChildPath = path.join(sourcePath, directory.name);
130
+ const targetChildPath = path.join(targetPath, directory.name);
131
+
132
+ if (await existsOrLink(targetChildPath)) {
133
+ const adopted = dryRun
134
+ ? await isAdoptableDirectoryBinding(sourceChildPath, targetChildPath)
135
+ : await adoptExistingDirectoryBinding({
136
+ tool,
137
+ sourcePath: sourceChildPath,
138
+ targetPath: targetChildPath,
139
+ osName,
140
+ bindings
141
+ });
142
+ if (!adopted) {
143
+ throw new Error(formatUnmanagedPathError(targetChildPath, profileName));
144
+ }
145
+ if (plannedActions) {
146
+ plannedActions.push({
147
+ tool,
148
+ kind: "dir",
149
+ targetPath: targetChildPath,
150
+ sourcePath: sourceChildPath,
151
+ action: "adopt-existing"
152
+ });
153
+ }
154
+ continue;
155
+ }
156
+
157
+ if (!dryRun) {
158
+ const method = await createDirectoryBinding(sourceChildPath, targetChildPath, osName);
159
+ createdTargets.push(targetChildPath);
160
+ bindings.push({
161
+ tool,
162
+ kind: "dir",
163
+ targetPath: targetChildPath,
164
+ sourcePath: sourceChildPath,
165
+ method,
166
+ hash: null,
167
+ managedBy: MANAGED_BY
168
+ });
169
+ }
170
+ if (plannedActions) {
171
+ plannedActions.push({
172
+ tool,
173
+ kind: "dir",
174
+ targetPath: targetChildPath,
175
+ sourcePath: sourceChildPath,
176
+ action: "create-binding"
177
+ });
178
+ }
179
+ }
180
+ }
181
+
182
+ async function adoptExistingDirectoryBinding({ tool, sourcePath, targetPath, osName, bindings }) {
183
+ if (!(await isAdoptableDirectoryBinding(sourcePath, targetPath))) {
184
+ return false;
185
+ }
186
+
187
+ bindings.push({
188
+ tool,
189
+ kind: "dir",
190
+ targetPath,
191
+ sourcePath,
192
+ method: osName === "windows" ? "junction" : "symlink",
193
+ hash: null,
194
+ managedBy: MANAGED_BY
195
+ });
196
+ return true;
197
+ }
198
+
199
+ export async function unlinkInternal(options = {}) {
200
+ const { suppressNoStateMessage = false, dryRun = false } = options;
201
+ const statePath = await getStatePath();
202
+ if (!(await fs.pathExists(statePath))) {
203
+ if (!suppressNoStateMessage) {
204
+ logInfo("No active bindings to unlink.");
205
+ }
206
+ return { removed: 0, skipped: 0, remainingBindings: [] };
207
+ }
208
+
209
+ const state = await fs.readJson(statePath);
210
+ const bindings = Array.isArray(state.bindings) ? state.bindings : [];
211
+ let removed = 0;
212
+ let skipped = 0;
213
+ const remainingBindings = [];
214
+
215
+ for (const binding of bindings) {
216
+ if (binding.kind === "config") {
217
+ try {
218
+ const result = await removeManagedMcpConfig(binding, { dryRun });
219
+ if (result.removed) {
220
+ removed += 1;
221
+ }
222
+ } catch (error) {
223
+ skipped += 1;
224
+ logWarn(`Skipping a config binding because managed MCP removal failed: ${redactPathDetails(error.message)}`);
225
+ remainingBindings.push(binding);
226
+ }
227
+ continue;
228
+ }
229
+
230
+ if (!(await existsOrLink(binding.targetPath))) {
231
+ continue;
232
+ }
233
+ if (!(await bindingMatches(binding))) {
234
+ skipped += 1;
235
+ logWarn("Skipping a binding because it no longer matches managed metadata.");
236
+ remainingBindings.push(binding);
237
+ continue;
238
+ }
239
+ if (!dryRun) {
240
+ await fs.remove(binding.targetPath);
241
+ }
242
+ removed += 1;
243
+ }
244
+
245
+ if (dryRun) {
246
+ return { removed, skipped, remainingBindings };
247
+ }
248
+
249
+ if (remainingBindings.length > 0) {
250
+ await writeJsonFile(statePath, {
251
+ ...state,
252
+ updatedAt: new Date().toISOString(),
253
+ bindings: remainingBindings
254
+ });
255
+ } else {
256
+ await fs.remove(statePath);
257
+ }
258
+ return { removed, skipped, remainingBindings };
259
+ }
260
+
261
+ export async function applyBindings(profileName, options = {}) {
262
+ const { dryRun = false, quiet = false } = options;
263
+ const info = (message) => {
264
+ if (!quiet) {
265
+ logInfo(message);
266
+ }
267
+ };
268
+ const osName = detectOsName();
269
+ const runtimeInternalRoot = RUNTIME_INTERNAL_ROOT;
270
+ const bundlePath = path.join(runtimeInternalRoot, "common", "bundle.json");
271
+ const bundleMcpPath = path.join(runtimeInternalRoot, "common", "mcp.json");
272
+ const requestedProfile = typeof profileName === "string" && profileName.trim().length > 0 ? profileName.trim() : null;
273
+ const profileHint = requestedProfile ?? "<name>";
274
+
275
+ if (!(await fs.pathExists(bundlePath))) {
276
+ throw new Error(`Missing runtime bundle metadata.\nRun build first: skills-sync build --profile ${profileHint}`);
277
+ }
278
+ if (!(await fs.pathExists(bundleMcpPath))) {
279
+ throw new Error(`Missing runtime bundle MCP manifest.\nRun build first: skills-sync build --profile ${profileHint}`);
280
+ }
281
+
282
+ let bundle;
283
+ try {
284
+ bundle = await fs.readJson(bundlePath);
285
+ } catch (error) {
286
+ throw new Error(`Failed to read runtime bundle metadata: ${error.message}`);
287
+ }
288
+ const bundleProfile = typeof bundle.profile === "string" && bundle.profile.trim().length > 0 ? bundle.profile.trim() : null;
289
+ const effectiveProfile = requestedProfile ?? bundleProfile;
290
+ if (!effectiveProfile) {
291
+ throw new Error("Could not determine profile for apply. Pass --profile <name> or rebuild runtime artifacts with profile metadata.");
292
+ }
293
+ if (!requestedProfile) {
294
+ info(`No --profile supplied. Using runtime bundle profile '${effectiveProfile}'.`);
295
+ } else {
296
+ info(`Using provided profile '${effectiveProfile}' for apply.`);
297
+ }
298
+ if (requestedProfile && bundleProfile && bundleProfile !== requestedProfile) {
299
+ throw new Error(
300
+ `Runtime artifacts are stale for requested profile '${requestedProfile}'. Found bundle profile '${bundleProfile}'.\n` +
301
+ `Run build first: skills-sync build --profile ${requestedProfile}`
302
+ );
303
+ }
304
+ const canonicalMcp = await fs.readJson(bundleMcpPath);
305
+
306
+ const effectiveTargets = await loadEffectiveTargets(osName);
307
+ const statePath = await getStatePath();
308
+ if (await fs.pathExists(statePath)) {
309
+ if (dryRun) {
310
+ info("Dry-run: existing state file detected. Apply would unlink previous managed bindings first.");
311
+ } else {
312
+ const unlinkResult = await unlinkInternal({ suppressNoStateMessage: true });
313
+ if (unlinkResult.remainingBindings.length > 0) {
314
+ throw new Error(
315
+ "Cannot continue apply because some previous bindings could not be safely unlinked.\n" +
316
+ "Run unlink/doctor, resolve reported paths manually, then retry apply."
317
+ );
318
+ }
319
+ }
320
+ }
321
+
322
+ const directorySpecs = getDirectoryBindingSpecs(effectiveTargets, runtimeInternalRoot);
323
+ const configSpecs = getConfigSpecs(effectiveTargets, runtimeInternalRoot);
324
+ const bindings = [];
325
+ const createdTargets = [];
326
+ const plannedActions = [];
327
+
328
+ try {
329
+ for (const spec of directorySpecs) {
330
+ const sourcePath = path.resolve(spec.sourcePath);
331
+ if (!(await fs.pathExists(sourcePath))) {
332
+ throw new Error(
333
+ "Source directory missing for apply.\n" +
334
+ `Run build first: skills-sync build --profile ${effectiveProfile}`
335
+ );
336
+ }
337
+
338
+ const targetPath = expandTargetPath(spec.targetRawPath, osName);
339
+ if (!dryRun) {
340
+ await fs.ensureDir(path.dirname(targetPath));
341
+ }
342
+ if (await existsOrLink(targetPath)) {
343
+ const adopted = dryRun
344
+ ? await isAdoptableDirectoryBinding(sourcePath, targetPath)
345
+ : await adoptExistingDirectoryBinding({
346
+ tool: spec.tool,
347
+ sourcePath,
348
+ targetPath,
349
+ osName,
350
+ bindings
351
+ });
352
+ if (adopted) {
353
+ plannedActions.push({
354
+ tool: spec.tool,
355
+ kind: "dir",
356
+ sourcePath,
357
+ targetPath,
358
+ action: "adopt-existing"
359
+ });
360
+ continue;
361
+ }
362
+
363
+ // Codex skills target may already exist as a user-managed parent directory.
364
+ if (spec.tool === "codex") {
365
+ const targetLstat = await fs.lstat(targetPath);
366
+ if (targetLstat.isSymbolicLink()) {
367
+ throw new Error(formatUnmanagedPathError(targetPath, effectiveProfile));
368
+ }
369
+ const targetStats = await fs.stat(targetPath);
370
+ if (!targetStats.isDirectory()) {
371
+ throw new Error(formatUnmanagedPathError(targetPath, effectiveProfile));
372
+ }
373
+ await bindNestedDirectoryEntries({
374
+ tool: spec.tool,
375
+ sourcePath,
376
+ targetPath,
377
+ osName,
378
+ profileName: effectiveProfile,
379
+ bindings,
380
+ createdTargets,
381
+ dryRun,
382
+ plannedActions
383
+ });
384
+ continue;
385
+ }
386
+ throw new Error(formatUnmanagedPathError(targetPath, effectiveProfile));
387
+ }
388
+
389
+ if (!dryRun) {
390
+ const method = await createDirectoryBinding(sourcePath, targetPath, osName);
391
+ createdTargets.push(targetPath);
392
+ bindings.push({
393
+ tool: spec.tool,
394
+ kind: "dir",
395
+ targetPath,
396
+ sourcePath,
397
+ method,
398
+ hash: null,
399
+ managedBy: MANAGED_BY
400
+ });
401
+ }
402
+ plannedActions.push({
403
+ tool: spec.tool,
404
+ kind: "dir",
405
+ sourcePath,
406
+ targetPath,
407
+ action: "create-binding"
408
+ });
409
+ }
410
+
411
+ for (const spec of configSpecs) {
412
+ const sourcePath = path.resolve(spec.sourcePath);
413
+ if (!(await fs.pathExists(sourcePath))) {
414
+ throw new Error(
415
+ "Source config missing for apply.\n" +
416
+ `Run build first: skills-sync build --profile ${effectiveProfile}`
417
+ );
418
+ }
419
+
420
+ const targetPath = expandTargetPath(spec.targetRawPath, osName);
421
+ const result = await applyManagedMcpConfig({
422
+ tool: spec.tool,
423
+ targetPath,
424
+ canonicalMcp,
425
+ dryRun
426
+ });
427
+
428
+ plannedActions.push({
429
+ tool: spec.tool,
430
+ kind: "config",
431
+ sourcePath,
432
+ targetPath,
433
+ action: result.wouldWrite ? "update-config" : "no-change"
434
+ });
435
+
436
+ if (!dryRun) {
437
+ bindings.push({
438
+ tool: spec.tool,
439
+ kind: "config",
440
+ targetPath,
441
+ sourcePath,
442
+ method: result.method,
443
+ hash: result.hash,
444
+ managedNames: result.managedNames,
445
+ managedBy: MANAGED_BY
446
+ });
447
+ }
448
+ }
449
+ } catch (error) {
450
+ if (!dryRun) {
451
+ for (const createdTarget of createdTargets) {
452
+ if (await existsOrLink(createdTarget)) {
453
+ await fs.remove(createdTarget);
454
+ }
455
+ }
456
+ }
457
+ throw error;
458
+ }
459
+
460
+ if (dryRun) {
461
+ const byTool = new Map();
462
+ for (const action of plannedActions) {
463
+ byTool.set(action.tool, (byTool.get(action.tool) ?? 0) + 1);
464
+ }
465
+ info(`Dry-run apply for profile '${effectiveProfile}' complete. No files were modified.`);
466
+ for (const tool of Array.from(byTool.keys()).sort((left, right) => left.localeCompare(right))) {
467
+ info(` ${tool}: ${byTool.get(tool)} planned action(s)`);
468
+ }
469
+ return {
470
+ dryRun: true,
471
+ profile: effectiveProfile,
472
+ os: osName,
473
+ plannedActions
474
+ };
475
+ }
476
+
477
+ const stateDocument = {
478
+ managedBy: MANAGED_BY,
479
+ profile: effectiveProfile,
480
+ os: osName,
481
+ appliedAt: new Date().toISOString(),
482
+ bindings
483
+ };
484
+ await writeJsonFile(statePath, stateDocument);
485
+
486
+ info(`Apply complete for profile '${effectiveProfile}'.`);
487
+ info(`Target OS: ${osName}`);
488
+ }
489
+
490
+ export async function unlinkBindings(options = {}) {
491
+ const { dryRun = false } = options;
492
+ const { removed, skipped, remainingBindings } = await unlinkInternal({ dryRun });
493
+ if (dryRun) {
494
+ logInfo(`Dry-run unlink complete. Would remove ${removed} binding(s), skip ${skipped} binding(s).`);
495
+ return { dryRun: true, removed, skipped, remainingBindings };
496
+ }
497
+ logInfo(`Unlink complete. Removed ${removed} binding(s), skipped ${skipped} binding(s).`);
498
+ if (remainingBindings.length > 0) {
499
+ logWarn("State file still contains unresolved bindings. Run doctor for remediation steps.");
500
+ }
501
+ return { dryRun: false, removed, skipped, remainingBindings };
502
+ }
503
+
504
+ export async function cmdApply(profileName, options = {}) {
505
+ return applyBindings(profileName, options);
506
+ }
507
+
508
+ export async function cmdUnlink(options = {}) {
509
+ return unlinkBindings(options);
510
+ }
@@ -0,0 +1,190 @@
1
+ import fs from "fs-extra";
2
+ import path from "node:path";
3
+ import { RUNTIME_INTERNAL_ROOT, SCHEMAS, detectOsName, expandTargetPath, logInfo, logWarn } from "./core.js";
4
+ import { loadEffectiveTargets, loadPackSources, normalizeMcpManifest, resolvePack, resolveProfile } from "./config.js";
5
+ import {
6
+ collectSourcePlanning,
7
+ loadLockfile,
8
+ loadUpstreamsConfig,
9
+ resolveReferences,
10
+ saveLockfile
11
+ } from "./upstreams.js";
12
+ import { assertJsonFileMatchesSchema } from "./core.js";
13
+ import { buildBundle } from "./bundle.js";
14
+ import { projectCodexFromBundle } from "./adapters/codex.js";
15
+ import { projectClaudeFromBundle } from "./adapters/claude.js";
16
+ import { projectCursorFromBundle } from "./adapters/cursor.js";
17
+ import { projectCopilotFromBundle } from "./adapters/copilot.js";
18
+ import { projectGeminiFromBundle } from "./adapters/gemini.js";
19
+
20
+ export { collectImportedSkillEntries, collectLocalSkillEntries } from "./bundle.js";
21
+
22
+ function sleep(ms) {
23
+ return new Promise((resolve) => {
24
+ setTimeout(resolve, ms);
25
+ });
26
+ }
27
+
28
+ async function removeDirectoryRobust(targetPath) {
29
+ const maxAttempts = 6;
30
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
31
+ try {
32
+ await fs.rm(targetPath, {
33
+ recursive: true,
34
+ force: true,
35
+ maxRetries: 5,
36
+ retryDelay: 100
37
+ });
38
+ return;
39
+ } catch (error) {
40
+ const code = error?.code;
41
+ const retryable = code === "EPERM" || code === "ENOTEMPTY" || code === "EBUSY";
42
+ if (!retryable || attempt === maxAttempts) {
43
+ throw error;
44
+ }
45
+ await sleep(150 * attempt);
46
+ }
47
+ }
48
+ }
49
+
50
+ export async function buildProfile(profileName, options = {}) {
51
+ const { quiet = false, lockMode = "write" } = options;
52
+
53
+ const { profile } = await resolveProfile(profileName);
54
+ const packRoot = await resolvePack(profile);
55
+
56
+ const packManifestPath = path.join(packRoot, "pack.json");
57
+ await assertJsonFileMatchesSchema(packManifestPath, SCHEMAS.packManifest);
58
+
59
+ const mcpServersPath = path.join(packRoot, "mcp", "servers.json");
60
+ const mcpServersManifest = await assertJsonFileMatchesSchema(mcpServersPath, SCHEMAS.mcpServers);
61
+ const normalizedMcp = normalizeMcpManifest(mcpServersManifest);
62
+
63
+ const { sources } = await loadPackSources(packRoot);
64
+ const upstreams = await loadUpstreamsConfig();
65
+ const lockState = await loadLockfile();
66
+
67
+ const { references, skillImports } = collectSourcePlanning(sources, upstreams.byId);
68
+ const lockConfigByMode = {
69
+ read: {
70
+ preferPinned: true,
71
+ requirePinned: true,
72
+ updatePins: false,
73
+ allowLockUpdate: false
74
+ },
75
+ write: {
76
+ preferPinned: true,
77
+ requirePinned: false,
78
+ updatePins: false,
79
+ allowLockUpdate: true
80
+ },
81
+ refresh: {
82
+ preferPinned: false,
83
+ requirePinned: false,
84
+ updatePins: true,
85
+ allowLockUpdate: true
86
+ }
87
+ };
88
+ const lockConfig = lockConfigByMode[lockMode];
89
+ if (!lockConfig) {
90
+ throw new Error(`Invalid lock mode '${lockMode}'. Use read, write, or refresh.`);
91
+ }
92
+
93
+ const resolvedReferences =
94
+ references.length > 0
95
+ ? await resolveReferences({
96
+ references,
97
+ upstreamById: upstreams.byId,
98
+ lockState,
99
+ ...lockConfig
100
+ })
101
+ : new Map();
102
+
103
+ if (lockState.changed && lockConfig.allowLockUpdate) {
104
+ await saveLockfile(lockState);
105
+ }
106
+
107
+ const runtimeInternalRoot = RUNTIME_INTERNAL_ROOT;
108
+ await removeDirectoryRobust(runtimeInternalRoot);
109
+ await fs.ensureDir(runtimeInternalRoot);
110
+
111
+ let localConfigPolicy = {};
112
+ try {
113
+ const osName = detectOsName();
114
+ const targets = await loadEffectiveTargets(osName);
115
+ localConfigPolicy = {
116
+ codex: {
117
+ path: expandTargetPath(targets.codex.mcpConfig, osName),
118
+ canOverride: Boolean(targets.codex?.canOverride)
119
+ },
120
+ claude: {
121
+ path: expandTargetPath(targets.claude.mcpConfig, osName),
122
+ canOverride: Boolean(targets.claude?.canOverride)
123
+ },
124
+ cursor: {
125
+ path: expandTargetPath(targets.cursor.mcpConfig, osName),
126
+ canOverride: Boolean(targets.cursor?.canOverride)
127
+ },
128
+ copilot: {
129
+ path: expandTargetPath(targets.copilot.mcpConfig, osName),
130
+ canOverride: Boolean(targets.copilot?.canOverride)
131
+ },
132
+ gemini: {
133
+ path: expandTargetPath(targets.gemini.mcpConfig, osName),
134
+ canOverride: Boolean(targets.gemini?.canOverride)
135
+ }
136
+ };
137
+ } catch {
138
+ if (!quiet) {
139
+ logWarn("Could not resolve local target config for merge-friendly projection seeding.");
140
+ }
141
+ }
142
+
143
+ const bundle = await buildBundle({
144
+ profile,
145
+ packRoot,
146
+ skillImports,
147
+ resolvedReferences,
148
+ normalizedMcp,
149
+ runtimeInternalRoot
150
+ });
151
+
152
+ const toolProjectors = [
153
+ { tool: "codex", projector: projectCodexFromBundle },
154
+ { tool: "claude", projector: projectClaudeFromBundle },
155
+ { tool: "cursor", projector: projectCursorFromBundle },
156
+ { tool: "copilot", projector: projectCopilotFromBundle },
157
+ { tool: "gemini", projector: projectGeminiFromBundle }
158
+ ];
159
+ for (const { tool, projector } of toolProjectors) {
160
+ await projector({
161
+ runtimeInternalRoot,
162
+ bundleSkillsPath: bundle.bundleSkillsPath,
163
+ bundleMcpPath: bundle.bundleMcpPath,
164
+ packRoot,
165
+ localConfigPath: localConfigPolicy[tool]?.path ?? null,
166
+ canOverride: localConfigPolicy[tool]?.canOverride ?? false
167
+ });
168
+ }
169
+
170
+ if (!quiet) {
171
+ logInfo(`Build complete for profile '${profileName}'.`);
172
+ logInfo("Runtime artifacts refreshed.");
173
+ logInfo(`Resolved upstream refs: ${references.length}`);
174
+ logInfo(`Lock mode: ${lockMode}`);
175
+ if (lockMode === "read" && references.length > 0) {
176
+ logWarn("Build ran with --lock=read. No upstream pins were written.");
177
+ }
178
+ }
179
+
180
+ return {
181
+ profile,
182
+ packRoot,
183
+ runtimeInternalRoot,
184
+ normalizedMcp,
185
+ sources,
186
+ skillEntries: bundle.skillEntries,
187
+ references,
188
+ resolvedReferences
189
+ };
190
+ }