link-agents 0.9.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 (184) hide show
  1. package/AGENTS.md +127 -0
  2. package/README.md +93 -0
  3. package/cursor-rules-notes.md +23 -0
  4. package/dist/cli/interactive.d.ts +9 -0
  5. package/dist/cli/interactive.d.ts.map +1 -0
  6. package/dist/cli/interactive.js +1176 -0
  7. package/dist/cli/interactive.js.map +1 -0
  8. package/dist/cli/options.d.ts +3 -0
  9. package/dist/cli/options.d.ts.map +1 -0
  10. package/dist/cli/options.js +107 -0
  11. package/dist/cli/options.js.map +1 -0
  12. package/dist/cli/options.spec.d.ts +2 -0
  13. package/dist/cli/options.spec.d.ts.map +1 -0
  14. package/dist/cli/options.spec.js +74 -0
  15. package/dist/cli/options.spec.js.map +1 -0
  16. package/dist/clients/definitions.d.ts +5 -0
  17. package/dist/clients/definitions.d.ts.map +1 -0
  18. package/dist/clients/definitions.js +82 -0
  19. package/dist/clients/definitions.js.map +1 -0
  20. package/dist/clients/definitions.spec.d.ts +2 -0
  21. package/dist/clients/definitions.spec.d.ts.map +1 -0
  22. package/dist/clients/definitions.spec.js +135 -0
  23. package/dist/clients/definitions.spec.js.map +1 -0
  24. package/dist/commands/doctor.d.ts +3 -0
  25. package/dist/commands/doctor.d.ts.map +1 -0
  26. package/dist/commands/doctor.js +81 -0
  27. package/dist/commands/doctor.js.map +1 -0
  28. package/dist/commands/restore.d.ts +3 -0
  29. package/dist/commands/restore.d.ts.map +1 -0
  30. package/dist/commands/restore.js +36 -0
  31. package/dist/commands/restore.js.map +1 -0
  32. package/dist/commands/sync.d.ts +3 -0
  33. package/dist/commands/sync.d.ts.map +1 -0
  34. package/dist/commands/sync.js +193 -0
  35. package/dist/commands/sync.js.map +1 -0
  36. package/dist/index.d.ts +3 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +30 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/types/index.d.ts +98 -0
  41. package/dist/types/index.d.ts.map +1 -0
  42. package/dist/types/index.js +2 -0
  43. package/dist/types/index.js.map +1 -0
  44. package/dist/utils/apply.d.ts +22 -0
  45. package/dist/utils/apply.d.ts.map +1 -0
  46. package/dist/utils/apply.js +215 -0
  47. package/dist/utils/apply.js.map +1 -0
  48. package/dist/utils/bootstrap.d.ts +18 -0
  49. package/dist/utils/bootstrap.d.ts.map +1 -0
  50. package/dist/utils/bootstrap.js +31 -0
  51. package/dist/utils/bootstrap.js.map +1 -0
  52. package/dist/utils/bootstrap.spec.d.ts +2 -0
  53. package/dist/utils/bootstrap.spec.d.ts.map +1 -0
  54. package/dist/utils/bootstrap.spec.js +92 -0
  55. package/dist/utils/bootstrap.spec.js.map +1 -0
  56. package/dist/utils/canonical.d.ts +17 -0
  57. package/dist/utils/canonical.d.ts.map +1 -0
  58. package/dist/utils/canonical.js +136 -0
  59. package/dist/utils/canonical.js.map +1 -0
  60. package/dist/utils/canonicalState.d.ts +19 -0
  61. package/dist/utils/canonicalState.d.ts.map +1 -0
  62. package/dist/utils/canonicalState.js +21 -0
  63. package/dist/utils/canonicalState.js.map +1 -0
  64. package/dist/utils/cursorHistory.d.ts +7 -0
  65. package/dist/utils/cursorHistory.d.ts.map +1 -0
  66. package/dist/utils/cursorHistory.js +54 -0
  67. package/dist/utils/cursorHistory.js.map +1 -0
  68. package/dist/utils/cursorPaths.d.ts +3 -0
  69. package/dist/utils/cursorPaths.d.ts.map +1 -0
  70. package/dist/utils/cursorPaths.js +17 -0
  71. package/dist/utils/cursorPaths.js.map +1 -0
  72. package/dist/utils/discovery.d.ts +8 -0
  73. package/dist/utils/discovery.d.ts.map +1 -0
  74. package/dist/utils/discovery.js +93 -0
  75. package/dist/utils/discovery.js.map +1 -0
  76. package/dist/utils/frontmatter.d.ts +32 -0
  77. package/dist/utils/frontmatter.d.ts.map +1 -0
  78. package/dist/utils/frontmatter.js +263 -0
  79. package/dist/utils/frontmatter.js.map +1 -0
  80. package/dist/utils/frontmatter.spec.d.ts +2 -0
  81. package/dist/utils/frontmatter.spec.d.ts.map +1 -0
  82. package/dist/utils/frontmatter.spec.js +264 -0
  83. package/dist/utils/frontmatter.spec.js.map +1 -0
  84. package/dist/utils/fs.d.ts +27 -0
  85. package/dist/utils/fs.d.ts.map +1 -0
  86. package/dist/utils/fs.js +137 -0
  87. package/dist/utils/fs.js.map +1 -0
  88. package/dist/utils/fs.spec.d.ts +2 -0
  89. package/dist/utils/fs.spec.d.ts.map +1 -0
  90. package/dist/utils/fs.spec.js +73 -0
  91. package/dist/utils/fs.spec.js.map +1 -0
  92. package/dist/utils/gitignore.d.ts +10 -0
  93. package/dist/utils/gitignore.d.ts.map +1 -0
  94. package/dist/utils/gitignore.js +63 -0
  95. package/dist/utils/gitignore.js.map +1 -0
  96. package/dist/utils/manifest.d.ts +28 -0
  97. package/dist/utils/manifest.d.ts.map +1 -0
  98. package/dist/utils/manifest.js +89 -0
  99. package/dist/utils/manifest.js.map +1 -0
  100. package/dist/utils/mcp.d.ts +73 -0
  101. package/dist/utils/mcp.d.ts.map +1 -0
  102. package/dist/utils/mcp.js +529 -0
  103. package/dist/utils/mcp.js.map +1 -0
  104. package/dist/utils/mcp.spec.d.ts +2 -0
  105. package/dist/utils/mcp.spec.d.ts.map +1 -0
  106. package/dist/utils/mcp.spec.js +488 -0
  107. package/dist/utils/mcp.spec.js.map +1 -0
  108. package/dist/utils/merge.d.ts +17 -0
  109. package/dist/utils/merge.d.ts.map +1 -0
  110. package/dist/utils/merge.js +45 -0
  111. package/dist/utils/merge.js.map +1 -0
  112. package/dist/utils/merge.spec.d.ts +2 -0
  113. package/dist/utils/merge.spec.d.ts.map +1 -0
  114. package/dist/utils/merge.spec.js +134 -0
  115. package/dist/utils/merge.spec.js.map +1 -0
  116. package/dist/utils/paths.d.ts +11 -0
  117. package/dist/utils/paths.d.ts.map +1 -0
  118. package/dist/utils/paths.js +164 -0
  119. package/dist/utils/paths.js.map +1 -0
  120. package/dist/utils/paths.spec.d.ts +2 -0
  121. package/dist/utils/paths.spec.d.ts.map +1 -0
  122. package/dist/utils/paths.spec.js +282 -0
  123. package/dist/utils/paths.spec.js.map +1 -0
  124. package/dist/utils/plan.d.ts +7 -0
  125. package/dist/utils/plan.d.ts.map +1 -0
  126. package/dist/utils/plan.js +118 -0
  127. package/dist/utils/plan.js.map +1 -0
  128. package/dist/utils/plan.spec.d.ts +2 -0
  129. package/dist/utils/plan.spec.d.ts.map +1 -0
  130. package/dist/utils/plan.spec.js +420 -0
  131. package/dist/utils/plan.spec.js.map +1 -0
  132. package/dist/utils/reporting.d.ts +21 -0
  133. package/dist/utils/reporting.d.ts.map +1 -0
  134. package/dist/utils/reporting.js +82 -0
  135. package/dist/utils/reporting.js.map +1 -0
  136. package/dist/utils/reporting.spec.d.ts +2 -0
  137. package/dist/utils/reporting.spec.d.ts.map +1 -0
  138. package/dist/utils/reporting.spec.js +78 -0
  139. package/dist/utils/reporting.spec.js.map +1 -0
  140. package/dist/utils/reset.d.ts +14 -0
  141. package/dist/utils/reset.d.ts.map +1 -0
  142. package/dist/utils/reset.js +81 -0
  143. package/dist/utils/reset.js.map +1 -0
  144. package/dist/utils/revert.d.ts +30 -0
  145. package/dist/utils/revert.d.ts.map +1 -0
  146. package/dist/utils/revert.js +89 -0
  147. package/dist/utils/revert.js.map +1 -0
  148. package/dist/utils/revert.spec.d.ts +2 -0
  149. package/dist/utils/revert.spec.d.ts.map +1 -0
  150. package/dist/utils/revert.spec.js +102 -0
  151. package/dist/utils/revert.spec.js.map +1 -0
  152. package/dist/utils/similarity.d.ts +14 -0
  153. package/dist/utils/similarity.d.ts.map +1 -0
  154. package/dist/utils/similarity.js +70 -0
  155. package/dist/utils/similarity.js.map +1 -0
  156. package/dist/utils/similarity.spec.d.ts +2 -0
  157. package/dist/utils/similarity.spec.d.ts.map +1 -0
  158. package/dist/utils/similarity.spec.js +62 -0
  159. package/dist/utils/similarity.spec.js.map +1 -0
  160. package/dist/utils/snapshots.d.ts +21 -0
  161. package/dist/utils/snapshots.d.ts.map +1 -0
  162. package/dist/utils/snapshots.js +81 -0
  163. package/dist/utils/snapshots.js.map +1 -0
  164. package/dist/utils/snapshots.spec.d.ts +2 -0
  165. package/dist/utils/snapshots.spec.d.ts.map +1 -0
  166. package/dist/utils/snapshots.spec.js +56 -0
  167. package/dist/utils/snapshots.spec.js.map +1 -0
  168. package/dist/utils/syncFilters.d.ts +3 -0
  169. package/dist/utils/syncFilters.d.ts.map +1 -0
  170. package/dist/utils/syncFilters.js +8 -0
  171. package/dist/utils/syncFilters.js.map +1 -0
  172. package/dist/utils/syncRuntime.d.ts +3 -0
  173. package/dist/utils/syncRuntime.d.ts.map +1 -0
  174. package/dist/utils/syncRuntime.js +31 -0
  175. package/dist/utils/syncRuntime.js.map +1 -0
  176. package/dist/utils/validation.d.ts +3 -0
  177. package/dist/utils/validation.d.ts.map +1 -0
  178. package/dist/utils/validation.js +19 -0
  179. package/dist/utils/validation.js.map +1 -0
  180. package/dist/utils/validation.spec.d.ts +2 -0
  181. package/dist/utils/validation.spec.d.ts.map +1 -0
  182. package/dist/utils/validation.spec.js +36 -0
  183. package/dist/utils/validation.spec.js.map +1 -0
  184. package/package.json +63 -0
@@ -0,0 +1,1176 @@
1
+ import { intro, outro, select, multiselect, confirm, spinner, note, } from "@clack/prompts";
2
+ import checkboxPlus from "inquirer-checkbox-plus-plus";
3
+ import chalk from "chalk";
4
+ import { discoverAssets } from "../utils/discovery.js";
5
+ import { fileExists, commandExists, readFileSafe, hashContent, } from "../utils/fs.js";
6
+ import { mergeMcpAssets, parseMcpConfig, detectMcpFormat, serializeMcpConfig, formatEnvForDisplay, compareServerConfigs, validateMcpConfig, getMcpCommands, findRemovedServers, } from "../utils/mcp.js";
7
+ import { calculateSimilarity, getSimilarityLabel, formatRelativeTime, } from "../utils/similarity.js";
8
+ import { remapRelativePathForTarget, buildTargetAbsolutePath, } from "../utils/paths.js";
9
+ import { shouldSkipTargetAsset } from "../utils/syncFilters.js";
10
+ /** Format asset count with type breakdown for display */
11
+ function formatAssetSummary(assets) {
12
+ if (assets.length === 0)
13
+ return "empty";
14
+ const byType = {};
15
+ for (const a of assets) {
16
+ byType[a.type] = (byType[a.type] || 0) + 1;
17
+ }
18
+ const typeLabels = {
19
+ agents: "agent",
20
+ commands: "cmd",
21
+ rules: "rule",
22
+ skills: "skill",
23
+ mcp: "mcp",
24
+ prompts: "prompt",
25
+ };
26
+ const parts = [];
27
+ for (const [type, count] of Object.entries(byType)) {
28
+ const label = typeLabels[type] || type;
29
+ parts.push(`${count} ${label}${count > 1 ? "s" : ""}`);
30
+ }
31
+ // If too many parts, show total with top 2 types
32
+ if (parts.length > 3) {
33
+ const total = assets.length;
34
+ const topTypes = Object.entries(byType)
35
+ .sort((a, b) => b[1] - a[1])
36
+ .slice(0, 2)
37
+ .map(([type, count]) => {
38
+ const label = typeLabels[type] || type;
39
+ return `${count} ${label}${count > 1 ? "s" : ""}`;
40
+ });
41
+ return `${total} files: ${topTypes.join(", ")}...`;
42
+ }
43
+ return parts.join(", ");
44
+ }
45
+ export async function runInteractiveFlow(defs, options) {
46
+ intro(chalk.bold("agsync"));
47
+ const scope = options.scope === "all" ? await selectScope() : options.scope ?? "all";
48
+ if (typeof scope === "symbol") {
49
+ outro("Cancelled.");
50
+ return { proceed: false, entries: [], scope: "all", direction: "sync" };
51
+ }
52
+ // Select direction FIRST (before scanning shows diff info)
53
+ const direction = options.direction && options.direction !== "sync"
54
+ ? options.direction
55
+ : await selectDirection(scope);
56
+ if (typeof direction === "symbol") {
57
+ outro("Cancelled.");
58
+ return { proceed: false, entries: [], scope, direction: "sync" };
59
+ }
60
+ const resolvedDirection = direction;
61
+ const s = spinner();
62
+ s.start("Scanning for assets...");
63
+ const scanResults = await scanAllClients(defs, scope);
64
+ s.stop("Scan complete.");
65
+ const projectAssets = scope === "global"
66
+ ? []
67
+ : scanResults.find((r) => r.client === "project")?.assets ?? [];
68
+ const globalAssets = scanResults
69
+ .filter((r) => r.client !== "project" && r.found)
70
+ .flatMap((r) => r.assets);
71
+ const allAssets = [...projectAssets, ...globalAssets];
72
+ if (allAssets.length === 0) {
73
+ note("No assets found. Create an AGENTS.md or rules to get started.", "Empty");
74
+ outro("Nothing to sync.");
75
+ return { proceed: false, entries: [], scope, direction: "sync" };
76
+ }
77
+ // Detect conflicts based on direction and scope
78
+ // Push: project is authoritative, only detect conflicts between global clients
79
+ // Pull: project is target, detect conflicts between global clients
80
+ // Sync: detect conflicts between all (but only globalAssets if scope is global)
81
+ const assetsForConflictDetection = resolvedDirection === "push"
82
+ ? globalAssets
83
+ : resolvedDirection === "pull"
84
+ ? globalAssets
85
+ : scope === "global"
86
+ ? globalAssets
87
+ : allAssets;
88
+ const conflicts = detectConflicts(assetsForConflictDetection);
89
+ if (conflicts.length > 0) {
90
+ console.log();
91
+ console.log(chalk.bold.yellow(`Found ${conflicts.length} conflict(s):`));
92
+ for (const conflict of conflicts) {
93
+ const resolution = await resolveConflict(conflict);
94
+ if (typeof resolution === "symbol") {
95
+ outro("Cancelled.");
96
+ return { proceed: false, entries: [], scope, direction: "sync" };
97
+ }
98
+ conflict.resolution = resolution;
99
+ }
100
+ }
101
+ // Now select target clients with diff info
102
+ const targetClients = await selectTargetClients(scanResults, scope, resolvedDirection, allAssets, conflicts, defs, options);
103
+ if (typeof targetClients === "symbol" || targetClients.length === 0) {
104
+ outro("Cancelled.");
105
+ return { proceed: false, entries: [], scope, direction: "sync" };
106
+ }
107
+ const plan = buildPlanFromConflicts(allAssets, conflicts, targetClients, defs, resolvedDirection, options);
108
+ if (plan.length === 0) {
109
+ note("All clients are already in sync.", "Up to date");
110
+ outro("Nothing to do.");
111
+ return { proceed: false, entries: [], scope, direction: resolvedDirection };
112
+ }
113
+ // Review all assets in a unified flow
114
+ const reviewedPlan = await reviewAllAssets(plan, allAssets);
115
+ if (typeof reviewedPlan === "symbol") {
116
+ outro("Cancelled.");
117
+ return { proceed: false, entries: [], scope, direction: resolvedDirection };
118
+ }
119
+ if (reviewedPlan.length === 0) {
120
+ note("All changes filtered out.", "Nothing to do");
121
+ outro("Nothing to apply.");
122
+ return { proceed: false, entries: [], scope, direction: resolvedDirection };
123
+ }
124
+ console.log();
125
+ console.log(chalk.bold("Planned changes:"));
126
+ for (const entry of reviewedPlan) {
127
+ const icon = entry.action === "create" ? chalk.green("+") : chalk.blue("~");
128
+ console.log(` ${icon} ${entry.targetClient} :: ${entry.targetRelativePath ?? entry.asset.relativePath}`);
129
+ }
130
+ console.log();
131
+ if (options.link === undefined) {
132
+ const writeMode = await select({
133
+ message: "How should files be written?",
134
+ options: [
135
+ {
136
+ value: "symlink",
137
+ label: "Symlink",
138
+ hint: "Recommended when target can point to the source file directly",
139
+ },
140
+ {
141
+ value: "copy",
142
+ label: "Copy",
143
+ hint: "Write independent file copies instead of symlinks",
144
+ },
145
+ ],
146
+ initialValue: "symlink",
147
+ });
148
+ if (typeof writeMode === "symbol") {
149
+ outro("Cancelled.");
150
+ return {
151
+ proceed: false,
152
+ entries: [],
153
+ scope,
154
+ direction: resolvedDirection,
155
+ };
156
+ }
157
+ options.link = writeMode === "symlink";
158
+ }
159
+ const confirmed = await confirm({
160
+ message: `Apply ${reviewedPlan.length} change(s)?`,
161
+ active: "Yes",
162
+ inactive: "No",
163
+ });
164
+ if (!confirmed || typeof confirmed === "symbol") {
165
+ outro("Cancelled.");
166
+ return { proceed: false, entries: [], scope, direction: resolvedDirection };
167
+ }
168
+ outro("Applying changes...");
169
+ return {
170
+ proceed: true,
171
+ entries: reviewedPlan,
172
+ scope,
173
+ direction: resolvedDirection,
174
+ };
175
+ }
176
+ async function selectScope() {
177
+ const result = await select({
178
+ message: "What would you like to sync?",
179
+ options: [
180
+ {
181
+ value: "global",
182
+ label: "Global configs",
183
+ hint: "~/.cursor, ~/.claude, ~/.codex, etc.",
184
+ },
185
+ {
186
+ value: "project",
187
+ label: "Project files",
188
+ hint: "./AGENTS.md, ./rules/*, etc.",
189
+ },
190
+ ],
191
+ });
192
+ return result;
193
+ }
194
+ async function selectDirection(scope) {
195
+ if (scope === "global") {
196
+ return "sync";
197
+ }
198
+ const result = await select({
199
+ message: "Sync direction:",
200
+ options: [
201
+ {
202
+ value: "push",
203
+ label: "Project → Global",
204
+ hint: "Push project rules to all clients",
205
+ },
206
+ {
207
+ value: "pull",
208
+ label: "Global → Project",
209
+ hint: "Pull client rules into project",
210
+ },
211
+ {
212
+ value: "sync",
213
+ label: "Merge all",
214
+ hint: "Combine and sync everywhere",
215
+ },
216
+ ],
217
+ });
218
+ return result;
219
+ }
220
+ async function selectTargetClients(scanResults, scope, direction, allAssets, conflicts, defs, options) {
221
+ // For sync mode, filter based on scope
222
+ // For push/pull, direction determines which clients are targets
223
+ const availableClients = scanResults.filter((r) => {
224
+ if (scope === "global")
225
+ return r.client !== "project";
226
+ // For project scope or all, show all clients
227
+ return true;
228
+ });
229
+ // Calculate diff for each client
230
+ function getDiffLabel(client) {
231
+ // Calculate what would be created/updated for this client
232
+ const plan = buildPlanFromConflicts(allAssets, conflicts, [client.client], defs, direction, options);
233
+ const creates = plan.filter((p) => p.action === "create").length;
234
+ const updates = plan.filter((p) => p.action === "update").length;
235
+ if (creates === 0 && updates === 0) {
236
+ return `${client.assets.length} files, no changes`;
237
+ }
238
+ const parts = [];
239
+ if (creates > 0)
240
+ parts.push(chalk.green(`+${creates}`));
241
+ if (updates > 0)
242
+ parts.push(chalk.blue(`~${updates}`));
243
+ return `${client.assets.length} files, ${parts.join(" ")}`;
244
+ }
245
+ const legend = chalk.dim("(+ new, ~ update)");
246
+ if (direction === "push") {
247
+ const globalClients = scanResults.filter((r) => r.client !== "project");
248
+ const result = await multiselect({
249
+ message: `Select target clients: ${legend}`,
250
+ options: globalClients.map((r) => ({
251
+ value: r.client,
252
+ label: `${r.displayName} (${getDiffLabel(r)})`,
253
+ })),
254
+ initialValues: globalClients.filter((r) => r.found).map((r) => r.client),
255
+ });
256
+ return result;
257
+ }
258
+ if (direction === "pull") {
259
+ return ["project"];
260
+ }
261
+ const result = await multiselect({
262
+ message: `Select target clients: ${legend}`,
263
+ options: availableClients.map((r) => ({
264
+ value: r.client,
265
+ label: `${r.displayName} (${getDiffLabel(r)})`,
266
+ })),
267
+ initialValues: availableClients.filter((r) => r.found).map((r) => r.client),
268
+ });
269
+ return result;
270
+ }
271
+ async function scanAllClients(defs, scope) {
272
+ const results = [];
273
+ for (const def of defs) {
274
+ // Always scan all clients to get full picture for diffing
275
+ // Scope filtering happens in client selection, not scanning
276
+ const exists = await fileExists(def.root);
277
+ if (!exists) {
278
+ results.push({
279
+ client: def.name,
280
+ displayName: def.displayName,
281
+ found: false,
282
+ assets: [],
283
+ root: def.root,
284
+ });
285
+ continue;
286
+ }
287
+ const assets = await discoverAssets([def], {});
288
+ results.push({
289
+ client: def.name,
290
+ displayName: def.displayName,
291
+ found: true,
292
+ assets,
293
+ root: def.root,
294
+ });
295
+ }
296
+ return results;
297
+ }
298
+ function detectConflicts(assets) {
299
+ const byKey = new Map();
300
+ for (const asset of assets) {
301
+ const key = `${asset.type}::${asset.canonicalPath ?? asset.relativePath}`;
302
+ const existing = byKey.get(key) ?? [];
303
+ existing.push(asset);
304
+ byKey.set(key, existing);
305
+ }
306
+ const conflicts = [];
307
+ for (const [key, versions] of byKey.entries()) {
308
+ const uniqueHashes = new Set(versions.map((v) => v.hash));
309
+ if (uniqueHashes.size > 1) {
310
+ // Sort by modification time, newest first
311
+ const sorted = versions.slice().sort((a, b) => {
312
+ const timeA = a.modifiedAt?.getTime() ?? 0;
313
+ const timeB = b.modifiedAt?.getTime() ?? 0;
314
+ return timeB - timeA;
315
+ });
316
+ conflicts.push({
317
+ canonicalKey: key,
318
+ type: versions[0].type,
319
+ versions: sorted,
320
+ });
321
+ }
322
+ }
323
+ return conflicts;
324
+ }
325
+ async function resolveConflict(conflict) {
326
+ const canMerge = conflict.type === "agents" || conflict.type === "mcp";
327
+ const [, filePath] = conflict.canonicalKey.split("::");
328
+ // Calculate similarity between first two versions
329
+ const similarity = conflict.versions.length >= 2
330
+ ? calculateSimilarity(conflict.versions[0].content, conflict.versions[1].content)
331
+ : 1;
332
+ // Auto-resolve if nearly identical (>=95%) - use newest version
333
+ if (similarity >= 0.95) {
334
+ conflict.selectedVersion = conflict.versions[0]; // Already sorted by time, newest first
335
+ return "source";
336
+ }
337
+ const similarityLabel = getSimilarityLabel(similarity);
338
+ const similarityPct = Math.round(similarity * 100);
339
+ const options = [];
340
+ // For MCP conflicts, put "Merge" first as the default (most common choice)
341
+ if (canMerge) {
342
+ options.push({
343
+ value: "merge",
344
+ label: "Merge (combine all servers)",
345
+ hint: "recommended",
346
+ });
347
+ }
348
+ // Add client version options
349
+ for (const v of conflict.versions) {
350
+ const clientLabel = v.client === "project" ? "local (./)" : v.client;
351
+ options.push({
352
+ value: v.client,
353
+ label: `Use ${clientLabel} (${(v.content.length / 1024).toFixed(1)}kb, ${formatRelativeTime(v.modifiedAt)})`,
354
+ });
355
+ }
356
+ // Add rename option for non-mergeable conflicts
357
+ if (!canMerge) {
358
+ options.push({
359
+ value: "rename",
360
+ label: "Keep both (rename)",
361
+ });
362
+ }
363
+ options.push({
364
+ value: "skip",
365
+ label: "Skip (keep as-is)",
366
+ });
367
+ const result = await select({
368
+ message: `${filePath} - ${similarityPct}% ${similarityLabel}`,
369
+ options,
370
+ });
371
+ if (typeof result === "symbol")
372
+ return result;
373
+ if (result === "rename")
374
+ return "rename";
375
+ if (result === "skip")
376
+ return "skip";
377
+ if (result === "merge") {
378
+ // For MCP merge, immediately ask which servers to include
379
+ if (conflict.type === "mcp") {
380
+ const selectedServers = await selectMcpServersForMerge(conflict.versions);
381
+ if (typeof selectedServers === "symbol")
382
+ return selectedServers;
383
+ // Store selected servers in conflict metadata for later use
384
+ conflict.resolvedContent = selectedServers;
385
+ }
386
+ return "merge";
387
+ }
388
+ conflict.selectedVersion = conflict.versions.find((v) => v.client === result);
389
+ const isSource = result === conflict.versions[0].client;
390
+ return isSource ? "source" : "target";
391
+ }
392
+ /**
393
+ * Let user select which MCP servers to include in merge
394
+ */
395
+ async function selectMcpServersForMerge(versions) {
396
+ // Collect all servers from all versions
397
+ const serversByName = new Map();
398
+ for (const version of versions) {
399
+ const format = detectMcpFormat(version.path);
400
+ const config = parseMcpConfig(version.content, format);
401
+ if (!config?.mcpServers)
402
+ continue;
403
+ for (const [serverName, serverConfig] of Object.entries(config.mcpServers)) {
404
+ const list = serversByName.get(serverName) ?? [];
405
+ list.push({ client: version.client, config: serverConfig });
406
+ serversByName.set(serverName, list);
407
+ }
408
+ }
409
+ if (serversByName.size === 0) {
410
+ return "{}"; // Empty config
411
+ }
412
+ // Build options for multiselect
413
+ const options = [];
414
+ for (const [serverName, sources] of serversByName.entries()) {
415
+ const sourceClients = sources.map((s) => s.client).join(", ");
416
+ options.push({
417
+ value: serverName,
418
+ label: serverName,
419
+ hint: `from ${sourceClients}`,
420
+ });
421
+ }
422
+ console.log();
423
+ const selected = await multiselect({
424
+ message: "Select MCP servers to include:",
425
+ options,
426
+ initialValues: Array.from(serversByName.keys()),
427
+ });
428
+ if (typeof selected === "symbol")
429
+ return selected;
430
+ // Build merged config with selected servers
431
+ const mergedServers = {};
432
+ for (const serverName of selected) {
433
+ const sources = serversByName.get(serverName);
434
+ if (sources && sources.length > 0) {
435
+ // Use first source's config (could add conflict resolution per-server later)
436
+ mergedServers[serverName] = sources[0].config;
437
+ }
438
+ }
439
+ // Serialize back to JSON
440
+ return JSON.stringify({ mcpServers: mergedServers }, null, 2);
441
+ }
442
+ function buildPlanFromConflicts(allAssets, conflicts, targetClients, defs, direction, options) {
443
+ const plan = [];
444
+ const conflictKeys = new Set(conflicts.map((c) => c.canonicalKey));
445
+ // Build index of what each client already has (by canonical path and hash)
446
+ const clientAssets = new Map(); // client -> (canonicalPath -> hash)
447
+ for (const asset of allAssets) {
448
+ const canonical = asset.canonicalPath ?? asset.relativePath;
449
+ if (!clientAssets.has(asset.client)) {
450
+ clientAssets.set(asset.client, new Map());
451
+ }
452
+ clientAssets.get(asset.client).set(canonical, asset.hash);
453
+ }
454
+ const resolvedAssets = new Map();
455
+ for (const asset of allAssets) {
456
+ const key = `${asset.type}::${asset.canonicalPath ?? asset.relativePath}`;
457
+ if (conflictKeys.has(key))
458
+ continue;
459
+ if (!resolvedAssets.has(key)) {
460
+ resolvedAssets.set(key, asset);
461
+ }
462
+ }
463
+ for (const conflict of conflicts) {
464
+ if (conflict.resolution === "skip")
465
+ continue;
466
+ if (conflict.resolution === "merge") {
467
+ let merged;
468
+ if (conflict.type === "mcp" && conflict.resolvedContent) {
469
+ // Use pre-selected MCP servers from conflict resolution
470
+ merged = conflict.resolvedContent;
471
+ }
472
+ else if (conflict.type === "mcp") {
473
+ // Fallback to smart MCP merging
474
+ const mcpMerged = mergeMcpAssets(conflict.versions);
475
+ merged =
476
+ mcpMerged ??
477
+ conflict.versions.map((v) => v.content).join("\n\n---\n\n");
478
+ }
479
+ else {
480
+ // Simple text concatenation for agents files
481
+ merged = conflict.versions.map((v) => v.content).join("\n\n---\n\n");
482
+ }
483
+ const base = conflict.versions[0];
484
+ resolvedAssets.set(conflict.canonicalKey, {
485
+ ...base,
486
+ content: merged,
487
+ hash: hashContent(merged),
488
+ });
489
+ }
490
+ else if (conflict.resolution === "rename") {
491
+ for (const version of conflict.versions) {
492
+ const renamedPath = addClientSuffix(version.canonicalPath ?? version.relativePath, version.client);
493
+ const renamedKey = `${version.type}::${renamedPath}`;
494
+ resolvedAssets.set(renamedKey, {
495
+ ...version,
496
+ canonicalPath: renamedPath,
497
+ relativePath: renamedPath,
498
+ });
499
+ }
500
+ }
501
+ else {
502
+ const winner = conflict.selectedVersion ??
503
+ (conflict.resolution === "source"
504
+ ? conflict.versions[0]
505
+ : conflict.versions[1]);
506
+ if (winner) {
507
+ resolvedAssets.set(conflict.canonicalKey, winner);
508
+ }
509
+ }
510
+ }
511
+ for (const [key, asset] of resolvedAssets.entries()) {
512
+ for (const clientName of targetClients) {
513
+ if (clientName === asset.client)
514
+ continue;
515
+ // Apply direction filtering
516
+ if (direction === "push" && asset.client !== "project") {
517
+ // In push mode, only sync FROM project to global clients
518
+ continue;
519
+ }
520
+ if (direction === "pull" && clientName !== "project") {
521
+ // In pull mode, only sync TO project
522
+ continue;
523
+ }
524
+ const def = defs.find((d) => d.name === clientName);
525
+ if (!def)
526
+ continue;
527
+ if (shouldSkipTargetAsset(options, clientName, asset)) {
528
+ continue;
529
+ }
530
+ const supportsType = def.assets.some((a) => a.type === asset.type);
531
+ if (!supportsType)
532
+ continue;
533
+ const canonical = asset.canonicalPath ?? asset.relativePath;
534
+ // Check if target already has this file with same content
535
+ const targetAssets = clientAssets.get(clientName);
536
+ if (targetAssets) {
537
+ const existingHash = targetAssets.get(canonical);
538
+ if (existingHash === asset.hash) {
539
+ // Target already has identical content, skip
540
+ continue;
541
+ }
542
+ }
543
+ // Remap path for target client (e.g., MCP configs have different filenames per client)
544
+ const targetRelative = remapRelativePathForTarget(asset, clientName, canonical, defs);
545
+ const targetPath = buildTargetAbsolutePath(def.root, targetRelative);
546
+ plan.push({
547
+ asset,
548
+ targetClient: clientName,
549
+ targetPath,
550
+ targetRelativePath: targetRelative,
551
+ action: targetAssets?.has(canonical) ? "update" : "create",
552
+ });
553
+ }
554
+ }
555
+ return plan;
556
+ }
557
+ function addClientSuffix(filePath, client) {
558
+ const lastDot = filePath.lastIndexOf(".");
559
+ if (lastDot === -1) {
560
+ return `${filePath}-${client}`;
561
+ }
562
+ const name = filePath.slice(0, lastDot);
563
+ const ext = filePath.slice(lastDot);
564
+ return `${name}-${client}${ext}`;
565
+ }
566
+ const ASSET_TYPE_LABELS = {
567
+ mcp: "MCP Servers",
568
+ commands: "Commands",
569
+ agents: "Agents",
570
+ rules: "Rules",
571
+ skills: "Skills",
572
+ prompts: "Prompts",
573
+ };
574
+ /** Check if asset is the root instructions file (AGENTS.md at root, not in agents/ folder) */
575
+ function isRootInstructionsFile(assetType, canonicalPath) {
576
+ return assetType === "agents" && canonicalPath === "AGENTS.md";
577
+ }
578
+ /** Format display name for assets */
579
+ function formatAssetDisplayName(assetType, canonicalPath) {
580
+ // For agents in agents/ folder, strip the prefix for cleaner display
581
+ if (assetType === "agents" && canonicalPath.startsWith("agents/")) {
582
+ return canonicalPath.slice(7); // Remove "agents/" prefix
583
+ }
584
+ return canonicalPath;
585
+ }
586
+ /**
587
+ * Unified review flow for all asset types
588
+ * 1. Resolve conflicts (ask user to pick version when same asset differs)
589
+ * 2. Show single grouped multiselect for all assets
590
+ */
591
+ async function reviewAllAssets(plan, allAssets) {
592
+ if (plan.length === 0)
593
+ return plan;
594
+ // Separate MCP from other assets
595
+ // MCP is handled during conflict resolution (merge step), so we just pass it through
596
+ const mcpEntries = plan.filter((e) => e.asset.type === "mcp");
597
+ const otherEntries = plan.filter((e) => e.asset.type !== "mcp");
598
+ // Process other assets and resolve conflicts
599
+ const otherResult = await resolveAssetConflicts(otherEntries);
600
+ if (typeof otherResult === "symbol")
601
+ return otherResult;
602
+ const allResolved = [...otherResult.resolved];
603
+ // If nothing to select (and no MCP), return early
604
+ if (allResolved.length === 0 && mcpEntries.length === 0) {
605
+ return [];
606
+ }
607
+ // If only MCP entries and no other assets, just return MCP entries
608
+ if (allResolved.length === 0 && mcpEntries.length > 0) {
609
+ return mcpEntries;
610
+ }
611
+ // If only one non-MCP asset and no conflicts, skip selection
612
+ if (allResolved.length === 1 && !otherResult.hadConflicts) {
613
+ // Include MCP entries directly since they're already resolved
614
+ return [
615
+ ...mcpEntries,
616
+ ...otherEntries.filter((e) => e.asset.canonicalPath === allResolved[0].name ||
617
+ e.asset.relativePath === allResolved[0].name),
618
+ ];
619
+ }
620
+ // Step 3: Separate root instructions (AGENTS.md) from sub-agents
621
+ const isRootInstructions = (type, name) => type === "agents" &&
622
+ (name === "AGENTS.md" || name.toUpperCase() === "AGENTS.MD");
623
+ const byType = new Map();
624
+ for (const resolved of allResolved) {
625
+ // Skip root instructions from resolved - we'll handle them separately
626
+ if (isRootInstructions(resolved.type, resolved.name)) {
627
+ continue;
628
+ }
629
+ const list = byType.get(resolved.type) ?? [];
630
+ list.push(resolved);
631
+ byType.set(resolved.type, list);
632
+ }
633
+ const selectedResolved = [];
634
+ // Step 4: Handle root instructions - only if there are plan entries for AGENTS.md
635
+ const rootPlanEntries = plan.filter((e) => isRootInstructions(e.asset.type, e.asset.canonicalPath ?? e.asset.relativePath));
636
+ if (rootPlanEntries.length > 0) {
637
+ // Collect all available sources for AGENTS.md
638
+ const rootSources = allAssets.filter((a) => a.type === "agents" &&
639
+ (a.canonicalPath === "AGENTS.md" || a.relativePath === "AGENTS.md"));
640
+ if (rootSources.length > 1) {
641
+ // Multiple sources - ask user which to use
642
+ console.log();
643
+ // Sort by modification time (most recent first)
644
+ const sortedSources = [...rootSources].sort((a, b) => {
645
+ const timeA = a.modifiedAt?.getTime() ?? 0;
646
+ const timeB = b.modifiedAt?.getTime() ?? 0;
647
+ return timeB - timeA; // Most recent first
648
+ });
649
+ const options = sortedSources.map((a) => {
650
+ const sizeKb = (a.content.length / 1024).toFixed(1);
651
+ const relTime = formatRelativeTime(a.modifiedAt);
652
+ return {
653
+ value: a.client,
654
+ label: a.client,
655
+ hint: `${sizeKb}kb, ${relTime}`,
656
+ };
657
+ });
658
+ options.push({ value: "__skip__", label: "Skip", hint: "" });
659
+ const sourceChoice = await select({
660
+ message: "AGENTS.md source:",
661
+ options,
662
+ initialValue: sortedSources[0].client, // Default to most recently modified
663
+ });
664
+ if (typeof sourceChoice === "symbol")
665
+ return sourceChoice;
666
+ if (sourceChoice !== "__skip__") {
667
+ const selectedSource = rootSources.find((a) => a.client === sourceChoice);
668
+ if (selectedSource) {
669
+ const sizeKb = (selectedSource.content.length / 1024).toFixed(1);
670
+ selectedResolved.push({
671
+ name: "AGENTS.md",
672
+ type: "agents",
673
+ version: {
674
+ client: selectedSource.client,
675
+ asset: selectedSource,
676
+ entry: rootPlanEntries[0],
677
+ },
678
+ label: `${selectedSource.client} (${sizeKb}kb)`,
679
+ });
680
+ }
681
+ }
682
+ }
683
+ else if (rootSources.length === 1) {
684
+ // Single source - auto-include it (already part of plan)
685
+ const source = rootSources[0];
686
+ const sizeKb = (source.content.length / 1024).toFixed(1);
687
+ selectedResolved.push({
688
+ name: "AGENTS.md",
689
+ type: "agents",
690
+ version: {
691
+ client: source.client,
692
+ asset: source,
693
+ entry: rootPlanEntries[0],
694
+ },
695
+ label: `${source.client} (${sizeKb}kb)`,
696
+ });
697
+ }
698
+ }
699
+ // Step 5: Process each type in a logical order
700
+ // Note: MCP is NOT included here - it's handled during conflict resolution (merge step)
701
+ const typeOrder = [
702
+ "agents",
703
+ "commands",
704
+ "prompts",
705
+ "rules",
706
+ "skills",
707
+ ];
708
+ // Threshold for using searchable multiselect vs regular multiselect
709
+ // Lower threshold for better UX - searchable is always nicer for lists > 10
710
+ const SEARCHABLE_THRESHOLD = 10;
711
+ for (const assetType of typeOrder) {
712
+ const assets = byType.get(assetType);
713
+ if (!assets || assets.length === 0)
714
+ continue;
715
+ // Deduplicate by name (same command from multiple sources should appear once)
716
+ const uniqueAssets = new Map();
717
+ for (const a of assets) {
718
+ if (!uniqueAssets.has(a.name)) {
719
+ uniqueAssets.set(a.name, a);
720
+ }
721
+ }
722
+ const deduped = Array.from(uniqueAssets.values());
723
+ // Sort alphabetically
724
+ deduped.sort((a, b) => a.name.localeCompare(b.name));
725
+ console.log();
726
+ let selectedNames;
727
+ if (deduped.length > SEARCHABLE_THRESHOLD) {
728
+ // Use searchable multiselect for large lists
729
+ const choices = deduped.map((a) => {
730
+ const displayName = formatAssetDisplayName(assetType, a.name);
731
+ return {
732
+ name: `${displayName} ${chalk.dim(`(${a.label})`)}`,
733
+ value: a.name,
734
+ short: displayName.split("/").pop() ?? displayName,
735
+ };
736
+ });
737
+ const selected = await checkboxPlus({
738
+ message: `${ASSET_TYPE_LABELS[assetType]} (${deduped.length}) - type to filter:`,
739
+ searchable: true,
740
+ highlight: true,
741
+ pageSize: 12,
742
+ default: deduped.map((a) => a.name), // Select all by default
743
+ source: async (_answers, input) => {
744
+ if (!input)
745
+ return choices;
746
+ const lower = input.toLowerCase();
747
+ return choices.filter((c) => c.value.toLowerCase().includes(lower));
748
+ },
749
+ });
750
+ // checkboxPlus returns array of values or throws on cancel
751
+ if (!Array.isArray(selected)) {
752
+ return Symbol("cancel");
753
+ }
754
+ selectedNames = new Set(selected);
755
+ }
756
+ else {
757
+ // Use regular multiselect for small lists
758
+ const options = deduped.map((a) => ({
759
+ value: a.name,
760
+ label: formatAssetDisplayName(assetType, a.name),
761
+ hint: a.label,
762
+ }));
763
+ const selected = await multiselect({
764
+ message: `${ASSET_TYPE_LABELS[assetType]} (${deduped.length}):`,
765
+ options,
766
+ initialValues: deduped.map((a) => a.name),
767
+ maxItems: 15,
768
+ });
769
+ if (typeof selected === "symbol")
770
+ return selected;
771
+ selectedNames = new Set(selected);
772
+ }
773
+ for (const asset of deduped) {
774
+ if (selectedNames.has(asset.name)) {
775
+ selectedResolved.push(asset);
776
+ }
777
+ }
778
+ }
779
+ if (selectedResolved.length === 0 && mcpEntries.length === 0) {
780
+ return [];
781
+ }
782
+ // Build final plan for non-MCP entries
783
+ const finalPlan = buildFinalPlanSimple(selectedResolved, otherEntries);
784
+ // Add MCP entries directly (already resolved during conflict resolution)
785
+ return [...mcpEntries, ...finalPlan];
786
+ }
787
+ /**
788
+ * Build final plan from selected assets (simplified, no MCP handling)
789
+ */
790
+ function buildFinalPlanSimple(selectedResolved, entries) {
791
+ const selectedPaths = new Set(selectedResolved.map((r) => `${r.type}::${r.name}`));
792
+ return entries.filter((e) => {
793
+ const key = `${e.asset.type}::${e.asset.canonicalPath ?? e.asset.relativePath}`;
794
+ return selectedPaths.has(key);
795
+ });
796
+ }
797
+ /**
798
+ * Resolve MCP server conflicts and prepare for selection
799
+ */
800
+ async function resolveMcpConflicts(mcpEntries) {
801
+ const resolved = [];
802
+ const serverChoices = new Map();
803
+ let hadConflicts = false;
804
+ if (mcpEntries.length === 0) {
805
+ return { resolved, serverChoices, hadConflicts };
806
+ }
807
+ // Collect servers by name
808
+ const serverVersions = new Map();
809
+ const seenServerClient = new Set();
810
+ for (const entry of mcpEntries) {
811
+ const format = detectMcpFormat(entry.asset.path);
812
+ const config = parseMcpConfig(entry.asset.content, format);
813
+ if (!config?.mcpServers)
814
+ continue;
815
+ for (const [serverName, serverConfig] of Object.entries(config.mcpServers)) {
816
+ const key = `${serverName}::${entry.asset.client}`;
817
+ if (seenServerClient.has(key))
818
+ continue;
819
+ seenServerClient.add(key);
820
+ const versions = serverVersions.get(serverName) ?? [];
821
+ versions.push({
822
+ client: entry.asset.client,
823
+ config: serverConfig,
824
+ entry,
825
+ format,
826
+ fullConfig: config,
827
+ });
828
+ serverVersions.set(serverName, versions);
829
+ }
830
+ }
831
+ // Process each server
832
+ for (const [serverName, versions] of serverVersions.entries()) {
833
+ if (versions.length === 1) {
834
+ const version = versions[0];
835
+ const envDisplay = formatEnvForDisplay(version.config.env);
836
+ serverChoices.set(serverName, version);
837
+ resolved.push({
838
+ name: serverName,
839
+ type: "mcp",
840
+ version: {
841
+ client: version.client,
842
+ asset: version.entry.asset,
843
+ entry: version.entry,
844
+ },
845
+ label: `${version.client} - ${envDisplay}`,
846
+ });
847
+ }
848
+ else {
849
+ // Check if versions differ
850
+ const first = versions[0];
851
+ let hasDifferences = false;
852
+ for (let i = 1; i < versions.length; i++) {
853
+ const comparison = compareServerConfigs(first.config, versions[i].config);
854
+ if (!comparison.same) {
855
+ hasDifferences = true;
856
+ break;
857
+ }
858
+ }
859
+ if (!hasDifferences) {
860
+ const envDisplay = formatEnvForDisplay(first.config.env);
861
+ serverChoices.set(serverName, first);
862
+ resolved.push({
863
+ name: serverName,
864
+ type: "mcp",
865
+ version: {
866
+ client: first.client,
867
+ asset: first.entry.asset,
868
+ entry: first.entry,
869
+ },
870
+ label: `${versions.map((v) => v.client).join(", ")} - ${envDisplay}`,
871
+ });
872
+ }
873
+ else {
874
+ hadConflicts = true;
875
+ // Show conflict and ask user
876
+ console.log();
877
+ console.log(chalk.yellow(`MCP server "${serverName}" differs across clients:`));
878
+ for (const version of versions) {
879
+ const envDisplay = formatEnvForDisplay(version.config.env);
880
+ console.log(` ${chalk.gray(version.client)}: ${version.config.command ?? "?"} - ${chalk.dim(envDisplay)}`);
881
+ }
882
+ const versionChoice = await select({
883
+ message: `Which "${serverName}" config to use?`,
884
+ options: [
885
+ ...versions.map((v) => ({
886
+ value: v.client,
887
+ label: `${v.client} (${v.config.command ?? "?"})`,
888
+ hint: formatEnvForDisplay(v.config.env),
889
+ })),
890
+ { value: "__skip__", label: "Skip this server" },
891
+ ],
892
+ });
893
+ if (typeof versionChoice === "symbol")
894
+ return versionChoice;
895
+ if (versionChoice !== "__skip__") {
896
+ const selected = versions.find((v) => v.client === versionChoice);
897
+ if (selected) {
898
+ const envDisplay = formatEnvForDisplay(selected.config.env);
899
+ serverChoices.set(serverName, selected);
900
+ resolved.push({
901
+ name: serverName,
902
+ type: "mcp",
903
+ version: {
904
+ client: selected.client,
905
+ asset: selected.entry.asset,
906
+ entry: selected.entry,
907
+ },
908
+ label: `${selected.client} - ${envDisplay}`,
909
+ });
910
+ }
911
+ }
912
+ }
913
+ }
914
+ }
915
+ return { resolved, serverChoices, hadConflicts };
916
+ }
917
+ /**
918
+ * Resolve conflicts for non-MCP assets
919
+ */
920
+ async function resolveAssetConflicts(entries) {
921
+ const resolved = [];
922
+ let hadConflicts = false;
923
+ if (entries.length === 0) {
924
+ return { resolved, hadConflicts };
925
+ }
926
+ // Group by type and canonical path (not name, which may differ between clients)
927
+ const assetVersions = new Map();
928
+ const seenAssetClient = new Set();
929
+ for (const entry of entries) {
930
+ const canonicalPath = entry.asset.canonicalPath ?? entry.asset.relativePath;
931
+ const key = `${entry.asset.type}::${canonicalPath}::${entry.asset.client}`;
932
+ if (seenAssetClient.has(key))
933
+ continue;
934
+ seenAssetClient.add(key);
935
+ const mapKey = `${entry.asset.type}::${canonicalPath}`;
936
+ const versions = assetVersions.get(mapKey) ?? [];
937
+ versions.push({
938
+ client: entry.asset.client,
939
+ asset: entry.asset,
940
+ entry,
941
+ });
942
+ assetVersions.set(mapKey, versions);
943
+ }
944
+ // Process each asset
945
+ for (const [mapKey, versions] of assetVersions.entries()) {
946
+ const [assetType, ...pathParts] = mapKey.split("::");
947
+ const canonicalPath = pathParts.join("::");
948
+ const type = assetType;
949
+ if (versions.length === 1) {
950
+ const version = versions[0];
951
+ const sizeKb = (version.asset.content.length / 1024).toFixed(1);
952
+ resolved.push({
953
+ name: canonicalPath,
954
+ type,
955
+ version,
956
+ label: `${version.client} (${sizeKb}kb)`,
957
+ });
958
+ }
959
+ else {
960
+ // Check if versions differ
961
+ const first = versions[0];
962
+ let hasDifferences = false;
963
+ for (let i = 1; i < versions.length; i++) {
964
+ if (versions[i].asset.hash !== first.asset.hash) {
965
+ hasDifferences = true;
966
+ break;
967
+ }
968
+ }
969
+ if (!hasDifferences) {
970
+ const sizeKb = (first.asset.content.length / 1024).toFixed(1);
971
+ resolved.push({
972
+ name: canonicalPath,
973
+ type,
974
+ version: first,
975
+ label: `${versions.map((v) => v.client).join(", ")} (${sizeKb}kb)`,
976
+ });
977
+ }
978
+ else {
979
+ hadConflicts = true;
980
+ // Sort by modification time (most recent first)
981
+ const sortedVersions = [...versions].sort((a, b) => {
982
+ const timeA = a.asset.modifiedAt?.getTime() ?? 0;
983
+ const timeB = b.asset.modifiedAt?.getTime() ?? 0;
984
+ return timeB - timeA;
985
+ });
986
+ // Show conflict
987
+ console.log();
988
+ console.log(chalk.yellow(`"${canonicalPath}" (${type}) differs across clients:`));
989
+ for (const version of sortedVersions) {
990
+ const sizeKb = (version.asset.content.length / 1024).toFixed(1);
991
+ const relTime = formatRelativeTime(version.asset.modifiedAt);
992
+ console.log(` ${chalk.gray(version.client)}: ${sizeKb}kb, ${relTime}`);
993
+ }
994
+ const similarity = calculateSimilarity(sortedVersions[0].asset.content, sortedVersions[1].asset.content);
995
+ console.log(chalk.dim(` Similarity: ${Math.round(similarity * 100)}% ${getSimilarityLabel(similarity)}`));
996
+ const versionChoice = await select({
997
+ message: `Which "${canonicalPath}" to use?`,
998
+ initialValue: sortedVersions[0].client, // Default to most recently modified
999
+ options: [
1000
+ ...sortedVersions.map((v) => ({
1001
+ value: v.client,
1002
+ label: `${v.client} (${(v.asset.content.length / 1024).toFixed(1)}kb)`,
1003
+ hint: formatRelativeTime(v.asset.modifiedAt),
1004
+ })),
1005
+ { value: "__skip__", label: "Skip" },
1006
+ ],
1007
+ });
1008
+ if (typeof versionChoice === "symbol")
1009
+ return versionChoice;
1010
+ if (versionChoice !== "__skip__") {
1011
+ const selected = sortedVersions.find((v) => v.client === versionChoice);
1012
+ if (selected) {
1013
+ const sizeKb = (selected.asset.content.length / 1024).toFixed(1);
1014
+ resolved.push({
1015
+ name: canonicalPath,
1016
+ type,
1017
+ version: selected,
1018
+ label: `${selected.client} (${sizeKb}kb)`,
1019
+ });
1020
+ }
1021
+ }
1022
+ }
1023
+ }
1024
+ }
1025
+ return { resolved, hadConflicts };
1026
+ }
1027
+ /**
1028
+ * Build final plan from selected assets
1029
+ */
1030
+ function buildFinalPlan(selectedResolved, mcpServerChoices, mcpEntries, otherEntries) {
1031
+ const finalPlan = [];
1032
+ // Handle MCP entries specially - need to rebuild config with selected servers
1033
+ const selectedMcpServers = new Set(selectedResolved.filter((r) => r.type === "mcp").map((r) => r.name));
1034
+ if (selectedMcpServers.size > 0) {
1035
+ // Group MCP entries by target client
1036
+ const mcpByTarget = new Map();
1037
+ for (const entry of mcpEntries) {
1038
+ const entries = mcpByTarget.get(entry.targetClient) ?? [];
1039
+ entries.push(entry);
1040
+ mcpByTarget.set(entry.targetClient, entries);
1041
+ }
1042
+ for (const [targetClient, entries] of mcpByTarget.entries()) {
1043
+ const mergedServers = {};
1044
+ for (const serverName of selectedMcpServers) {
1045
+ const choice = mcpServerChoices.get(serverName);
1046
+ if (choice) {
1047
+ mergedServers[serverName] = choice.config;
1048
+ }
1049
+ }
1050
+ if (Object.keys(mergedServers).length === 0)
1051
+ continue;
1052
+ const firstEntry = entries[0];
1053
+ const format = detectMcpFormat(firstEntry.asset.path);
1054
+ const baseConfig = parseMcpConfig(firstEntry.asset.content, format) ?? {};
1055
+ const finalConfig = {
1056
+ ...baseConfig,
1057
+ mcpServers: mergedServers,
1058
+ };
1059
+ const serializedContent = serializeMcpConfig(finalConfig, format);
1060
+ finalPlan.push({
1061
+ ...firstEntry,
1062
+ asset: {
1063
+ ...firstEntry.asset,
1064
+ content: serializedContent,
1065
+ hash: hashContent(serializedContent),
1066
+ },
1067
+ });
1068
+ }
1069
+ }
1070
+ // Handle other entries - use canonical paths for matching (r.name is now canonicalPath)
1071
+ const selectedOther = new Map();
1072
+ for (const r of selectedResolved) {
1073
+ if (r.type !== "mcp") {
1074
+ selectedOther.set(`${r.type}::${r.name}`, r);
1075
+ }
1076
+ }
1077
+ const processedTargets = new Set();
1078
+ for (const entry of otherEntries) {
1079
+ const canonicalPath = entry.asset.canonicalPath ?? entry.asset.relativePath;
1080
+ const key = `${entry.asset.type}::${canonicalPath}`;
1081
+ const resolved = selectedOther.get(key);
1082
+ if (!resolved)
1083
+ continue;
1084
+ const targetKey = `${key}::${entry.targetClient}`;
1085
+ if (processedTargets.has(targetKey))
1086
+ continue;
1087
+ processedTargets.add(targetKey);
1088
+ // Use resolved version's content
1089
+ if (entry.asset.client === resolved.version.client) {
1090
+ finalPlan.push(entry);
1091
+ }
1092
+ else {
1093
+ finalPlan.push({
1094
+ ...entry,
1095
+ asset: resolved.version.asset,
1096
+ });
1097
+ }
1098
+ }
1099
+ return finalPlan;
1100
+ }
1101
+ /**
1102
+ * Validate MCP configs and warn about potential issues
1103
+ */
1104
+ async function validateAndWarnMcp(newEntries, originalEntries) {
1105
+ const warnings = [];
1106
+ const errors = [];
1107
+ const missingCommands = new Set();
1108
+ // Validate each new MCP config
1109
+ for (const entry of newEntries) {
1110
+ const format = detectMcpFormat(entry.asset.path);
1111
+ const validation = validateMcpConfig(entry.asset.content, format);
1112
+ for (const err of validation.errors) {
1113
+ errors.push(`${entry.targetClient}: ${err}`);
1114
+ }
1115
+ for (const warn of validation.warnings) {
1116
+ warnings.push(`${entry.targetClient}: ${warn}`);
1117
+ }
1118
+ // Check if commands exist
1119
+ const config = parseMcpConfig(entry.asset.content, format);
1120
+ if (config) {
1121
+ const commands = getMcpCommands(config);
1122
+ for (const cmd of commands) {
1123
+ const exists = await commandExists(cmd);
1124
+ if (!exists) {
1125
+ missingCommands.add(cmd);
1126
+ }
1127
+ }
1128
+ }
1129
+ }
1130
+ // Check for servers being removed from targets
1131
+ for (const newEntry of newEntries) {
1132
+ const format = detectMcpFormat(newEntry.asset.path);
1133
+ const newConfig = parseMcpConfig(newEntry.asset.content, format);
1134
+ if (!newConfig)
1135
+ continue;
1136
+ // Find original config for same target client
1137
+ for (const origEntry of originalEntries) {
1138
+ if (origEntry.targetClient !== newEntry.targetClient)
1139
+ continue;
1140
+ // Read existing file at target to check what would be removed
1141
+ const existingContent = await readFileSafe(newEntry.targetPath);
1142
+ if (!existingContent)
1143
+ continue;
1144
+ // Use target file's format, not source
1145
+ const targetFormat = detectMcpFormat(newEntry.targetPath);
1146
+ const existingConfig = parseMcpConfig(existingContent, targetFormat);
1147
+ if (!existingConfig)
1148
+ continue;
1149
+ const removed = findRemovedServers(newConfig, existingConfig);
1150
+ for (const serverName of removed) {
1151
+ warnings.push(`${newEntry.targetClient}: server "${serverName}" will be removed`);
1152
+ }
1153
+ }
1154
+ }
1155
+ // Display warnings
1156
+ if (missingCommands.size > 0) {
1157
+ console.log();
1158
+ console.log(chalk.yellow(`Warning: Commands not found in PATH: ${Array.from(missingCommands).join(", ")}`));
1159
+ console.log(chalk.dim(" These MCP servers may not work correctly."));
1160
+ }
1161
+ if (warnings.length > 0) {
1162
+ console.log();
1163
+ console.log(chalk.yellow("Warnings:"));
1164
+ for (const warn of warnings) {
1165
+ console.log(chalk.yellow(` - ${warn}`));
1166
+ }
1167
+ }
1168
+ if (errors.length > 0) {
1169
+ console.log();
1170
+ console.log(chalk.red("Errors:"));
1171
+ for (const err of errors) {
1172
+ console.log(chalk.red(` - ${err}`));
1173
+ }
1174
+ }
1175
+ }
1176
+ //# sourceMappingURL=interactive.js.map