@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,888 @@
1
+ import fs from "fs-extra";
2
+ import path from "node:path";
3
+ import {
4
+ MCP_MANAGED_PREFIX,
5
+ SCHEMAS,
6
+ assertJsonFileMatchesSchema,
7
+ assertObjectMatchesSchema,
8
+ detectOsName,
9
+ expandTargetPath,
10
+ logInfo,
11
+ logWarn,
12
+ writeJsonFile
13
+ } from "./core.js";
14
+ import { applyBindings } from "./bindings.js";
15
+ import { buildProfile } from "./build.js";
16
+ import { loadEffectiveTargets, readDefaultProfile, resolvePack, resolveProfile } from "./config.js";
17
+ import { buildProfileInventory } from "./inventory.js";
18
+
19
+ const AGENT_ORDER = ["codex", "claude", "cursor", "copilot", "gemini"];
20
+ const AGENT_SET = new Set(AGENT_ORDER);
21
+
22
+ function normalizeOptionalText(value) {
23
+ if (typeof value !== "string") {
24
+ return null;
25
+ }
26
+ const normalized = value.trim();
27
+ return normalized.length > 0 ? normalized : null;
28
+ }
29
+
30
+ function sortStrings(values) {
31
+ return [...values].sort((left, right) => left.localeCompare(right));
32
+ }
33
+
34
+ function redactPathDetails(message) {
35
+ return String(message ?? "")
36
+ .replace(/[A-Za-z]:\\[^\s'"]+/g, "<path>")
37
+ .replace(/~\/[^\s'"]+/g, "<path>")
38
+ .replace(/\/(?:[^/\s]+\/)+[^/\s]+/g, "<path>")
39
+ .replace(/\b[\w.-]+\.(json|toml|md)\b/g, "<file>");
40
+ }
41
+
42
+ function normalizeDriftMcpName(value) {
43
+ const normalized = String(value ?? "").trim();
44
+ if (
45
+ normalized.startsWith(MCP_MANAGED_PREFIX) &&
46
+ normalized.length > MCP_MANAGED_PREFIX.length
47
+ ) {
48
+ return normalized.slice(MCP_MANAGED_PREFIX.length);
49
+ }
50
+ return normalized;
51
+ }
52
+
53
+ function normalizeDriftMcpNames(values) {
54
+ const names = new Set();
55
+ for (const value of values) {
56
+ const normalized = normalizeDriftMcpName(value);
57
+ if (normalized.length === 0) {
58
+ continue;
59
+ }
60
+ names.add(normalized);
61
+ }
62
+ return sortStrings(Array.from(names));
63
+ }
64
+
65
+ function toPosixPath(value) {
66
+ return value.split(path.sep).join("/");
67
+ }
68
+
69
+ function parseAgentTokenList(rawAgents) {
70
+ if (!rawAgents) {
71
+ return [];
72
+ }
73
+ const raw = Array.isArray(rawAgents) ? rawAgents.join(",") : String(rawAgents);
74
+ return raw
75
+ .split(/[,\s]+/g)
76
+ .map((item) => item.trim().toLowerCase())
77
+ .filter((item) => item.length > 0);
78
+ }
79
+
80
+ export function parseAgentFilterOption(rawAgents) {
81
+ const tokens = parseAgentTokenList(rawAgents);
82
+ if (tokens.length === 0) {
83
+ return [...AGENT_ORDER];
84
+ }
85
+
86
+ const requested = new Set();
87
+ for (const token of tokens) {
88
+ if (!AGENT_SET.has(token)) {
89
+ throw new Error(`Unknown agent '${token}'. Valid values: ${AGENT_ORDER.join(", ")}.`);
90
+ }
91
+ requested.add(token);
92
+ }
93
+
94
+ return AGENT_ORDER.filter((name) => requested.has(name));
95
+ }
96
+
97
+ async function detectSkillDirectories(skillsRoot, currentRelative = "", entries = []) {
98
+ const absolute = currentRelative.length > 0 ? path.join(skillsRoot, currentRelative) : skillsRoot;
99
+ const children = await fs.readdir(absolute, { withFileTypes: true });
100
+
101
+ let hasSkill = false;
102
+ for (const child of children) {
103
+ if (child.isFile() && child.name === "SKILL.md") {
104
+ hasSkill = true;
105
+ break;
106
+ }
107
+ }
108
+
109
+ if (hasSkill && currentRelative.length > 0) {
110
+ entries.push(toPosixPath(currentRelative));
111
+ }
112
+
113
+ const directories = children
114
+ .filter((child) => child.isDirectory())
115
+ .map((child) => child.name)
116
+ .sort((left, right) => left.localeCompare(right));
117
+
118
+ for (const directory of directories) {
119
+ const nextRelative = currentRelative.length > 0 ? path.join(currentRelative, directory) : directory;
120
+ await detectSkillDirectories(skillsRoot, nextRelative, entries);
121
+ }
122
+
123
+ return entries;
124
+ }
125
+
126
+ function parseTomlTableKey(rawToken) {
127
+ const token = String(rawToken ?? "").trim();
128
+ if (token.length === 0) {
129
+ throw new Error("Empty MCP server table key.");
130
+ }
131
+ if (token.startsWith("\"")) {
132
+ try {
133
+ const parsed = JSON.parse(token);
134
+ if (typeof parsed !== "string" || parsed.trim().length === 0) {
135
+ throw new Error("Expected non-empty string.");
136
+ }
137
+ return parsed;
138
+ } catch (error) {
139
+ throw new Error(`Invalid quoted MCP server key '${token}': ${error.message}`);
140
+ }
141
+ }
142
+ if (!/^[A-Za-z0-9_.-]+$/.test(token)) {
143
+ throw new Error(`Invalid bare MCP server key '${token}'.`);
144
+ }
145
+ return token;
146
+ }
147
+
148
+ function parseTomlStringValue(rawValue) {
149
+ const trimmed = String(rawValue ?? "").trim();
150
+ if (trimmed.length === 0) {
151
+ return null;
152
+ }
153
+ if (
154
+ (trimmed.startsWith("\"") && trimmed.endsWith("\"")) ||
155
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
156
+ ) {
157
+ try {
158
+ return JSON.parse(trimmed);
159
+ } catch {
160
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
161
+ return trimmed.slice(1, -1);
162
+ }
163
+ return null;
164
+ }
165
+ }
166
+ return trimmed;
167
+ }
168
+
169
+ function parseTomlArrayValue(rawValue) {
170
+ const parsed = parseTomlStringValue(rawValue);
171
+ if (Array.isArray(parsed)) {
172
+ return parsed.map((item) => String(item));
173
+ }
174
+ if (typeof rawValue !== "string") {
175
+ return [];
176
+ }
177
+ const trimmed = rawValue.trim();
178
+ if (!(trimmed.startsWith("[") && trimmed.endsWith("]"))) {
179
+ return [];
180
+ }
181
+ try {
182
+ const jsonParsed = JSON.parse(trimmed);
183
+ return Array.isArray(jsonParsed) ? jsonParsed.map((item) => String(item)) : [];
184
+ } catch {
185
+ return [];
186
+ }
187
+ }
188
+
189
+ function parseTomlInlineTableValue(rawValue) {
190
+ const value = String(rawValue ?? "").trim();
191
+ if (!(value.startsWith("{") && value.endsWith("}"))) {
192
+ return {};
193
+ }
194
+ const body = value.slice(1, -1).trim();
195
+ if (body.length === 0) {
196
+ return {};
197
+ }
198
+
199
+ const env = {};
200
+ const pairPattern = /("[^"]+"|[A-Za-z0-9_.-]+)\s*=\s*("([^"\\]|\\.)*"|'([^'\\]|\\.)*'|[^,}]+)/g;
201
+ for (const match of body.matchAll(pairPattern)) {
202
+ const rawKey = match[1];
203
+ const rawVal = match[2];
204
+ const key = parseTomlTableKey(rawKey);
205
+ const parsedValue = parseTomlStringValue(rawVal);
206
+ env[key] = parsedValue === null ? String(rawVal).trim() : String(parsedValue);
207
+ }
208
+ return env;
209
+ }
210
+
211
+ function parseCodexMcpTableHeader(line) {
212
+ const trimmed = String(line ?? "").trim();
213
+ const match = trimmed.match(/^\[mcp_servers\.(.+)\]$/);
214
+ if (!match) {
215
+ return null;
216
+ }
217
+ return parseTomlTableKey(match[1]);
218
+ }
219
+
220
+ function parseCodexMcpServerBlock(name, blockLines) {
221
+ let command = null;
222
+ let url = null;
223
+ let transport = null;
224
+ let args = [];
225
+ let env = {};
226
+
227
+ for (const line of blockLines) {
228
+ const trimmed = line.trim();
229
+ if (trimmed.length === 0 || trimmed.startsWith("#")) {
230
+ continue;
231
+ }
232
+ const pairMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/);
233
+ if (!pairMatch) {
234
+ continue;
235
+ }
236
+ const key = pairMatch[1];
237
+ const value = pairMatch[2];
238
+ switch (key) {
239
+ case "command":
240
+ command = parseTomlStringValue(value);
241
+ break;
242
+ case "url":
243
+ url = parseTomlStringValue(value);
244
+ break;
245
+ case "transport":
246
+ transport = parseTomlStringValue(value);
247
+ break;
248
+ case "args":
249
+ args = parseTomlArrayValue(value);
250
+ break;
251
+ case "env":
252
+ env = parseTomlInlineTableValue(value);
253
+ break;
254
+ default:
255
+ break;
256
+ }
257
+ }
258
+
259
+ return {
260
+ name,
261
+ command: typeof command === "string" && command.trim().length > 0 ? command.trim() : null,
262
+ url: typeof url === "string" && url.trim().length > 0 ? url.trim() : null,
263
+ transport: typeof transport === "string" && transport.trim().length > 0 ? transport.trim() : null,
264
+ args: Array.isArray(args) ? args : [],
265
+ env
266
+ };
267
+ }
268
+
269
+ async function readCodexInstalledMcpServers(configPath) {
270
+ const content = await fs.readFile(configPath, "utf8");
271
+ const lines = content.replace(/\r\n/g, "\n").split("\n");
272
+ const servers = [];
273
+
274
+ let index = 0;
275
+ while (index < lines.length) {
276
+ const tableName = parseCodexMcpTableHeader(lines[index]);
277
+ if (!tableName) {
278
+ index += 1;
279
+ continue;
280
+ }
281
+ index += 1;
282
+ const blockLines = [];
283
+ while (index < lines.length) {
284
+ const candidate = lines[index].trim();
285
+ if (/^\[.+\]$/.test(candidate)) {
286
+ break;
287
+ }
288
+ blockLines.push(lines[index]);
289
+ index += 1;
290
+ }
291
+ servers.push(parseCodexMcpServerBlock(tableName, blockLines));
292
+ }
293
+
294
+ return servers.sort((left, right) => left.name.localeCompare(right.name));
295
+ }
296
+
297
+ function normalizeJsonMcpServer(name, value) {
298
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
299
+ throw new Error(`mcpServers['${name}'] must be an object.`);
300
+ }
301
+ return {
302
+ name,
303
+ command: typeof value.command === "string" ? value.command : null,
304
+ url: typeof value.url === "string" ? value.url : null,
305
+ transport: typeof value.transport === "string" ? value.transport : null,
306
+ args: Array.isArray(value.args) ? value.args.map((item) => String(item)) : [],
307
+ env:
308
+ value.env && typeof value.env === "object" && !Array.isArray(value.env)
309
+ ? Object.fromEntries(
310
+ Object.entries(value.env)
311
+ .filter(([key]) => key.length > 0)
312
+ .sort(([left], [right]) => left.localeCompare(right))
313
+ .map(([key, envValue]) => [key, String(envValue)])
314
+ )
315
+ : {}
316
+ };
317
+ }
318
+
319
+ async function readJsonInstalledMcpServers(configPath) {
320
+ let doc;
321
+ try {
322
+ doc = await fs.readJson(configPath);
323
+ } catch (error) {
324
+ throw new Error(`Failed to parse JSON config: ${error.message}`);
325
+ }
326
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
327
+ throw new Error("Expected JSON object root.");
328
+ }
329
+ if (!doc.mcpServers || typeof doc.mcpServers !== "object" || Array.isArray(doc.mcpServers)) {
330
+ throw new Error("Expected object field 'mcpServers'.");
331
+ }
332
+
333
+ return sortStrings(Object.keys(doc.mcpServers)).map((name) => normalizeJsonMcpServer(name, doc.mcpServers[name]));
334
+ }
335
+
336
+ async function readInstalledMcpServers(tool, configPath) {
337
+ if (tool === "codex") {
338
+ return readCodexInstalledMcpServers(configPath);
339
+ }
340
+ return readJsonInstalledMcpServers(configPath);
341
+ }
342
+
343
+ function buildAgentRows(osName, targets, agents) {
344
+ return agents.map((tool) => {
345
+ const target = targets?.[tool];
346
+ if (!target) {
347
+ throw new Error(`Missing target mapping for agent '${tool}'.`);
348
+ }
349
+ const skillsRaw = typeof target.skillsDir === "string" && target.skillsDir.trim().length > 0 ? target.skillsDir : null;
350
+ const mcpRaw = typeof target.mcpConfig === "string" && target.mcpConfig.trim().length > 0 ? target.mcpConfig : null;
351
+ if (!mcpRaw) {
352
+ throw new Error(`Missing MCP config target path for agent '${tool}'.`);
353
+ }
354
+ return {
355
+ tool,
356
+ support: skillsRaw ? "skills+mcp" : "mcp-only",
357
+ skillsDir: skillsRaw ? expandTargetPath(skillsRaw, osName) : null,
358
+ mcpConfig: expandTargetPath(mcpRaw, osName),
359
+ canOverride: Boolean(target?.canOverride)
360
+ };
361
+ });
362
+ }
363
+
364
+ function formatErrorMessage(error) {
365
+ return error instanceof Error ? error.message : String(error);
366
+ }
367
+
368
+ export async function collectAgentInventories({ agents } = {}) {
369
+ const osName = detectOsName();
370
+ const targets = await loadEffectiveTargets(osName);
371
+ const selectedAgents = parseAgentFilterOption(agents);
372
+ const rows = buildAgentRows(osName, targets, selectedAgents);
373
+
374
+ const detailedRows = [];
375
+ for (const row of rows) {
376
+ const hasSkillsPath = row.skillsDir ? await fs.pathExists(row.skillsDir).catch(() => false) : false;
377
+ const hasMcpPath = await fs.pathExists(row.mcpConfig).catch(() => false);
378
+
379
+ const parseErrors = [];
380
+ let skills = [];
381
+ if (row.skillsDir && hasSkillsPath) {
382
+ try {
383
+ skills = sortStrings(await detectSkillDirectories(row.skillsDir));
384
+ } catch (error) {
385
+ parseErrors.push({
386
+ kind: "skills",
387
+ path: row.skillsDir,
388
+ message: formatErrorMessage(error)
389
+ });
390
+ }
391
+ }
392
+
393
+ let mcpServers = [];
394
+ if (hasMcpPath) {
395
+ try {
396
+ mcpServers = await readInstalledMcpServers(row.tool, row.mcpConfig);
397
+ } catch (error) {
398
+ parseErrors.push({
399
+ kind: "mcp",
400
+ path: row.mcpConfig,
401
+ message: formatErrorMessage(error)
402
+ });
403
+ }
404
+ }
405
+
406
+ detailedRows.push({
407
+ ...row,
408
+ hasSkillsPath,
409
+ hasMcpPath,
410
+ installed: hasSkillsPath || hasMcpPath,
411
+ inventory: {
412
+ skills,
413
+ mcpServers
414
+ },
415
+ parseErrors
416
+ });
417
+ }
418
+
419
+ return {
420
+ os: osName,
421
+ agents: detailedRows
422
+ };
423
+ }
424
+
425
+ function formatSkillsList(skills) {
426
+ if (skills.length === 0) {
427
+ return [" (none)"];
428
+ }
429
+ return skills.map((item) => ` ${item}`);
430
+ }
431
+
432
+ function formatMcpServersList(servers) {
433
+ if (servers.length === 0) {
434
+ return [" (none)"];
435
+ }
436
+ return servers.map((server) => ` ${server.name}`);
437
+ }
438
+
439
+ function formatParseErrors(parseErrors) {
440
+ if (parseErrors.length === 0) {
441
+ return [" parse errors : none"];
442
+ }
443
+ const lines = [` parse errors : ${parseErrors.length}`];
444
+ for (const issue of parseErrors) {
445
+ lines.push(` [${issue.kind}] ${redactPathDetails(issue.message)}`);
446
+ }
447
+ return lines;
448
+ }
449
+
450
+ function inventoryToText(payload) {
451
+ const lines = [`Detected host OS: ${payload.os}`, ""];
452
+ for (const agent of payload.agents) {
453
+ lines.push(agent.tool);
454
+ lines.push(` status : ${agent.installed ? "detected" : "not detected"}`);
455
+ lines.push(` support : ${agent.support}`);
456
+ lines.push(` skills : ${agent.inventory.skills.length}`);
457
+ lines.push(...formatSkillsList(agent.inventory.skills));
458
+ lines.push(` mcp servers : ${agent.inventory.mcpServers.length}`);
459
+ lines.push(...formatMcpServersList(agent.inventory.mcpServers));
460
+ lines.push(...formatParseErrors(agent.parseErrors));
461
+ lines.push("");
462
+ }
463
+ return lines.join("\n").trimEnd();
464
+ }
465
+
466
+ function computeDifference(expected, actual) {
467
+ const expectedSet = new Set(expected);
468
+ const actualSet = new Set(actual);
469
+ const missing = [];
470
+ const extra = [];
471
+
472
+ for (const item of expectedSet) {
473
+ if (!actualSet.has(item)) {
474
+ missing.push(item);
475
+ }
476
+ }
477
+ for (const item of actualSet) {
478
+ if (!expectedSet.has(item)) {
479
+ extra.push(item);
480
+ }
481
+ }
482
+
483
+ return {
484
+ missing: sortStrings(missing),
485
+ extra: sortStrings(extra)
486
+ };
487
+ }
488
+
489
+ function formatDriftSection(label, drift) {
490
+ const lines = [` ${label} missing (${drift.missing.length})`];
491
+ if (drift.missing.length === 0) {
492
+ lines.push(" (none)");
493
+ } else {
494
+ for (const item of drift.missing) {
495
+ lines.push(` ${item}`);
496
+ }
497
+ }
498
+
499
+ lines.push(` ${label} extra (${drift.extra.length})`);
500
+ if (drift.extra.length === 0) {
501
+ lines.push(" (none)");
502
+ } else {
503
+ for (const item of drift.extra) {
504
+ lines.push(` ${item}`);
505
+ }
506
+ }
507
+
508
+ return lines;
509
+ }
510
+
511
+ function driftToText(driftReport) {
512
+ const lines = [];
513
+ lines.push(`Profile: ${driftReport.profile}`);
514
+ lines.push(`Expected skills: ${driftReport.expected.skills.length}`);
515
+ lines.push(`Expected MCP servers: ${driftReport.expected.mcpServers.length}`);
516
+ lines.push("");
517
+
518
+ for (const agent of driftReport.agents) {
519
+ lines.push(agent.tool);
520
+ lines.push(` status : ${agent.installed ? "detected" : "not detected"}`);
521
+ lines.push(...formatDriftSection("skills", agent.drift.skills));
522
+ lines.push(...formatDriftSection("mcp", agent.drift.mcpServers));
523
+ lines.push(...formatParseErrors(agent.parseErrors));
524
+ lines.push(
525
+ ` summary : missing=${agent.summary.missingTotal} extra=${agent.summary.extraTotal} parseErrors=${agent.summary.parseErrors}`
526
+ );
527
+ lines.push("");
528
+ }
529
+
530
+ return lines.join("\n").trimEnd();
531
+ }
532
+
533
+ function toPublicParseErrors(parseErrors) {
534
+ return parseErrors.map((issue) => ({
535
+ kind: issue.kind,
536
+ message: redactPathDetails(issue.message)
537
+ }));
538
+ }
539
+
540
+ function toPublicInventoryPayload(payload) {
541
+ return {
542
+ os: payload.os,
543
+ agents: payload.agents.map((agent) => ({
544
+ tool: agent.tool,
545
+ support: agent.support,
546
+ canOverride: agent.canOverride,
547
+ installed: agent.installed,
548
+ hasSkillsPath: agent.hasSkillsPath,
549
+ hasMcpPath: agent.hasMcpPath,
550
+ inventory: agent.inventory,
551
+ parseErrors: toPublicParseErrors(agent.parseErrors)
552
+ }))
553
+ };
554
+ }
555
+
556
+ function toPublicDriftPayload(payload) {
557
+ return {
558
+ os: payload.os,
559
+ profile: payload.profile,
560
+ expected: payload.expected,
561
+ agents: payload.agents.map((agent) => ({
562
+ tool: agent.tool,
563
+ support: agent.support,
564
+ installed: agent.installed,
565
+ parseErrors: toPublicParseErrors(agent.parseErrors),
566
+ drift: agent.drift,
567
+ summary: agent.summary
568
+ }))
569
+ };
570
+ }
571
+
572
+ function normalizeMcpEnv(rawEnv) {
573
+ if (!rawEnv || typeof rawEnv !== "object" || Array.isArray(rawEnv)) {
574
+ return {};
575
+ }
576
+ const normalized = {};
577
+ for (const key of Object.keys(rawEnv).sort((left, right) => left.localeCompare(right))) {
578
+ if (key.length === 0) {
579
+ continue;
580
+ }
581
+ normalized[key] = String(rawEnv[key]);
582
+ }
583
+ return normalized;
584
+ }
585
+
586
+ function normalizeDiscoveredMcpServerSpec(server) {
587
+ if (!server || typeof server !== "object") {
588
+ return null;
589
+ }
590
+ if (typeof server.url === "string" && server.url.trim().length > 0) {
591
+ return {
592
+ url: server.url.trim()
593
+ };
594
+ }
595
+ if (typeof server.command !== "string" || server.command.trim().length === 0) {
596
+ return null;
597
+ }
598
+ const normalized = {
599
+ command: server.command.trim(),
600
+ args: Array.isArray(server.args) ? server.args.map((item) => String(item)) : []
601
+ };
602
+ const env = normalizeMcpEnv(server.env);
603
+ if (Object.keys(env).length > 0) {
604
+ normalized.env = env;
605
+ }
606
+ return normalized;
607
+ }
608
+
609
+ function normalizeStoredProfileMcpSpec(server) {
610
+ if (!server || typeof server !== "object" || Array.isArray(server)) {
611
+ return null;
612
+ }
613
+ if (typeof server.url === "string" && server.url.trim().length > 0) {
614
+ return {
615
+ url: server.url.trim()
616
+ };
617
+ }
618
+ if (typeof server.command !== "string" || server.command.trim().length === 0) {
619
+ return null;
620
+ }
621
+ const normalized = {
622
+ command: server.command.trim(),
623
+ args: Array.isArray(server.args) ? server.args.map((item) => String(item)) : []
624
+ };
625
+ const env = normalizeMcpEnv(server.env);
626
+ if (Object.keys(env).length > 0) {
627
+ normalized.env = env;
628
+ }
629
+ return normalized;
630
+ }
631
+
632
+ function sortObjectDeep(value) {
633
+ if (Array.isArray(value)) {
634
+ return value.map((item) => sortObjectDeep(item));
635
+ }
636
+ if (!value || typeof value !== "object") {
637
+ return value;
638
+ }
639
+ const sorted = {};
640
+ for (const key of Object.keys(value).sort((left, right) => left.localeCompare(right))) {
641
+ sorted[key] = sortObjectDeep(value[key]);
642
+ }
643
+ return sorted;
644
+ }
645
+
646
+ function mcpServerSignature(server) {
647
+ return JSON.stringify(sortObjectDeep(server ?? {}));
648
+ }
649
+
650
+ function collectExtraMcpCandidates({ drift, inventory }) {
651
+ const inventoryByTool = new Map(
652
+ (inventory?.agents ?? []).map((agent) => [agent.tool, agent])
653
+ );
654
+ const byName = new Map();
655
+ const unresolved = [];
656
+
657
+ for (const driftAgent of drift.agents) {
658
+ const agentInventory = inventoryByTool.get(driftAgent.tool);
659
+ if (!agentInventory) {
660
+ continue;
661
+ }
662
+ const extras = driftAgent.drift?.mcpServers?.extra ?? [];
663
+ for (const extraName of extras) {
664
+ const matched = (agentInventory.inventory?.mcpServers ?? []).find(
665
+ (server) => normalizeDriftMcpName(server.name) === extraName
666
+ );
667
+ if (!matched) {
668
+ unresolved.push({
669
+ name: extraName,
670
+ tool: driftAgent.tool,
671
+ reason: "server definition not found in detected inventory"
672
+ });
673
+ continue;
674
+ }
675
+ const normalized = normalizeDiscoveredMcpServerSpec(matched);
676
+ if (!normalized) {
677
+ unresolved.push({
678
+ name: extraName,
679
+ tool: driftAgent.tool,
680
+ reason: "unsupported server definition"
681
+ });
682
+ continue;
683
+ }
684
+ const candidates = byName.get(extraName) ?? [];
685
+ candidates.push({
686
+ tool: driftAgent.tool,
687
+ server: normalized
688
+ });
689
+ byName.set(extraName, candidates);
690
+ }
691
+ }
692
+
693
+ return {
694
+ byName,
695
+ unresolved
696
+ };
697
+ }
698
+
699
+ function chooseMcpCandidate(candidates) {
700
+ const sorted = [...candidates].sort(
701
+ (left, right) => AGENT_ORDER.indexOf(left.tool) - AGENT_ORDER.indexOf(right.tool)
702
+ );
703
+ const selected = sorted[0] ?? null;
704
+ const conflicts = [];
705
+ if (!selected) {
706
+ return { selected: null, conflicts };
707
+ }
708
+ const selectedSignature = mcpServerSignature(selected.server);
709
+ for (const candidate of sorted.slice(1)) {
710
+ if (mcpServerSignature(candidate.server) !== selectedSignature) {
711
+ conflicts.push({
712
+ keptTool: selected.tool,
713
+ ignoredTool: candidate.tool
714
+ });
715
+ }
716
+ }
717
+ return { selected, conflicts };
718
+ }
719
+
720
+ async function promoteExtraMcpServersIntoProfile({ profile, drift, agents }) {
721
+ const candidates = collectExtraMcpCandidates({ drift, inventory: agents });
722
+ const selectedByName = new Map();
723
+ const conflicts = [];
724
+
725
+ for (const [name, entries] of candidates.byName.entries()) {
726
+ const { selected, conflicts: selectionConflicts } = chooseMcpCandidate(entries);
727
+ if (!selected) {
728
+ continue;
729
+ }
730
+ selectedByName.set(name, selected.server);
731
+ for (const conflict of selectionConflicts) {
732
+ conflicts.push({
733
+ name,
734
+ ...conflict
735
+ });
736
+ }
737
+ }
738
+
739
+ if (selectedByName.size === 0) {
740
+ return {
741
+ added: [],
742
+ unchanged: [],
743
+ conflicts,
744
+ unresolved: candidates.unresolved
745
+ };
746
+ }
747
+
748
+ const { profile: profileDoc } = await resolveProfile(profile);
749
+ const packRoot = await resolvePack(profileDoc);
750
+ const mcpPath = path.join(packRoot, "mcp", "servers.json");
751
+ const doc = (await fs.pathExists(mcpPath))
752
+ ? await assertJsonFileMatchesSchema(mcpPath, SCHEMAS.mcpServers)
753
+ : { servers: {} };
754
+ if (!doc.servers || typeof doc.servers !== "object" || Array.isArray(doc.servers)) {
755
+ doc.servers = {};
756
+ }
757
+
758
+ const added = [];
759
+ const unchanged = [];
760
+ for (const name of Array.from(selectedByName.keys()).sort((left, right) => left.localeCompare(right))) {
761
+ const proposed = selectedByName.get(name);
762
+ const existing = normalizeStoredProfileMcpSpec(doc.servers[name]);
763
+ if (existing) {
764
+ if (mcpServerSignature(existing) === mcpServerSignature(proposed)) {
765
+ unchanged.push(name);
766
+ } else {
767
+ conflicts.push({
768
+ name,
769
+ keptTool: "profile",
770
+ ignoredTool: "detected-agents"
771
+ });
772
+ }
773
+ continue;
774
+ }
775
+ doc.servers[name] = proposed;
776
+ added.push(name);
777
+ }
778
+
779
+ await assertObjectMatchesSchema(doc, SCHEMAS.mcpServers, mcpPath);
780
+ await writeJsonFile(mcpPath, doc);
781
+
782
+ return {
783
+ added,
784
+ unchanged,
785
+ conflicts,
786
+ unresolved: candidates.unresolved
787
+ };
788
+ }
789
+
790
+ export async function buildAgentDrift({ profile, agents } = {}) {
791
+ const explicitProfile = normalizeOptionalText(profile);
792
+ const resolvedProfile = explicitProfile ?? (await readDefaultProfile());
793
+ if (!resolvedProfile) {
794
+ throw new Error(
795
+ "Profile is required. Provide --profile <name> or set a default with 'skills-sync use <name>'."
796
+ );
797
+ }
798
+
799
+ const expectedInventory = await buildProfileInventory(resolvedProfile);
800
+ const expectedSkills = sortStrings([
801
+ ...expectedInventory.skills.local.map((item) => item.name),
802
+ ...expectedInventory.skills.imports.map((item) => item.destRelative)
803
+ ]);
804
+ const expectedMcpServers = normalizeDriftMcpNames(
805
+ expectedInventory.mcp.servers.map((item) => item.name)
806
+ );
807
+
808
+ const detected = await collectAgentInventories({ agents });
809
+ const driftAgents = detected.agents.map((agent) => {
810
+ const actualSkills = sortStrings(agent.inventory.skills);
811
+ const actualMcp = normalizeDriftMcpNames(
812
+ agent.inventory.mcpServers.map((item) => item.name)
813
+ );
814
+ const skillsDrift = computeDifference(expectedSkills, actualSkills);
815
+ const mcpDrift = computeDifference(expectedMcpServers, actualMcp);
816
+
817
+ return {
818
+ tool: agent.tool,
819
+ support: agent.support,
820
+ installed: agent.installed,
821
+ parseErrors: agent.parseErrors,
822
+ drift: {
823
+ skills: skillsDrift,
824
+ mcpServers: mcpDrift
825
+ },
826
+ summary: {
827
+ missingTotal: skillsDrift.missing.length + mcpDrift.missing.length,
828
+ extraTotal: skillsDrift.extra.length + mcpDrift.extra.length,
829
+ parseErrors: agent.parseErrors.length
830
+ }
831
+ };
832
+ });
833
+
834
+ return {
835
+ os: detected.os,
836
+ profile: resolvedProfile,
837
+ expected: {
838
+ skills: expectedSkills,
839
+ mcpServers: expectedMcpServers
840
+ },
841
+ agents: driftAgents
842
+ };
843
+ }
844
+
845
+ export async function cmdAgentInventory({ format = "text", agents } = {}) {
846
+ const inventory = await collectAgentInventories({ agents });
847
+ if (format === "json") {
848
+ process.stdout.write(`${JSON.stringify(toPublicInventoryPayload(inventory), null, 2)}\n`);
849
+ return;
850
+ }
851
+ process.stdout.write(`${inventoryToText(inventory)}\n`);
852
+ }
853
+
854
+ export async function cmdAgentDrift({ profile, dryRun = false, format = "text", agents } = {}) {
855
+ const initialDrift = await buildAgentDrift({ profile, agents });
856
+ if (!dryRun) {
857
+ const detectedInventory = await collectAgentInventories({ agents });
858
+ const promotion = await promoteExtraMcpServersIntoProfile({
859
+ profile: initialDrift.profile,
860
+ drift: initialDrift,
861
+ agents: detectedInventory
862
+ });
863
+ if (format !== "json") {
864
+ if (promotion.added.length > 0) {
865
+ logInfo(
866
+ `Adopted ${promotion.added.length} MCP drift entr${promotion.added.length === 1 ? "y" : "ies"} into profile '${initialDrift.profile}': ${promotion.added.join(", ")}`
867
+ );
868
+ }
869
+ for (const issue of promotion.unresolved) {
870
+ logWarn(`Could not adopt MCP drift '${issue.name}' from ${issue.tool}: ${issue.reason}.`);
871
+ }
872
+ for (const issue of promotion.conflicts) {
873
+ logWarn(
874
+ `MCP drift conflict for '${issue.name}': kept ${issue.keptTool}, ignored ${issue.ignoredTool}.`
875
+ );
876
+ }
877
+ }
878
+ await buildProfile(initialDrift.profile, { lockMode: "write", quiet: format === "json" });
879
+ await applyBindings(initialDrift.profile, { dryRun: false, quiet: format === "json" });
880
+ }
881
+
882
+ const drift = dryRun ? initialDrift : await buildAgentDrift({ profile: initialDrift.profile, agents });
883
+ if (format === "json") {
884
+ process.stdout.write(`${JSON.stringify(toPublicDriftPayload(drift), null, 2)}\n`);
885
+ return;
886
+ }
887
+ process.stdout.write(`${driftToText(drift)}\n`);
888
+ }