xtrm-cli 0.5.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 (93) hide show
  1. package/.gemini/settings.json +39 -0
  2. package/dist/index.cjs +57378 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +2 -0
  5. package/extensions/beads.ts +109 -0
  6. package/extensions/core/adapter.ts +45 -0
  7. package/extensions/core/lib.ts +3 -0
  8. package/extensions/core/logger.ts +45 -0
  9. package/extensions/core/runner.ts +71 -0
  10. package/extensions/custom-footer.ts +160 -0
  11. package/extensions/main-guard-post-push.ts +44 -0
  12. package/extensions/main-guard.ts +126 -0
  13. package/extensions/minimal-mode.ts +201 -0
  14. package/extensions/quality-gates.ts +67 -0
  15. package/extensions/service-skills.ts +150 -0
  16. package/extensions/xtrm-loader.ts +89 -0
  17. package/hooks/gitnexus-impact-reminder.py +13 -0
  18. package/lib/atomic-config.js +236 -0
  19. package/lib/config-adapter.js +231 -0
  20. package/lib/config-injector.js +80 -0
  21. package/lib/context.js +73 -0
  22. package/lib/diff.js +142 -0
  23. package/lib/env-manager.js +160 -0
  24. package/lib/sync-mcp-cli.js +345 -0
  25. package/lib/sync.js +227 -0
  26. package/package.json +47 -0
  27. package/src/adapters/base.ts +29 -0
  28. package/src/adapters/claude.ts +38 -0
  29. package/src/adapters/registry.ts +21 -0
  30. package/src/commands/claude.ts +122 -0
  31. package/src/commands/clean.ts +371 -0
  32. package/src/commands/end.ts +239 -0
  33. package/src/commands/finish.ts +25 -0
  34. package/src/commands/help.ts +180 -0
  35. package/src/commands/init.ts +959 -0
  36. package/src/commands/install-pi.ts +276 -0
  37. package/src/commands/install-service-skills.ts +281 -0
  38. package/src/commands/install.ts +427 -0
  39. package/src/commands/pi-install.ts +119 -0
  40. package/src/commands/pi.ts +128 -0
  41. package/src/commands/reset.ts +12 -0
  42. package/src/commands/status.ts +170 -0
  43. package/src/commands/worktree.ts +193 -0
  44. package/src/core/context.ts +141 -0
  45. package/src/core/diff.ts +174 -0
  46. package/src/core/interactive-plan.ts +165 -0
  47. package/src/core/manifest.ts +26 -0
  48. package/src/core/preflight.ts +142 -0
  49. package/src/core/rollback.ts +32 -0
  50. package/src/core/session-state.ts +139 -0
  51. package/src/core/sync-executor.ts +427 -0
  52. package/src/core/xtrm-finish.ts +267 -0
  53. package/src/index.ts +87 -0
  54. package/src/tests/policy-parity.test.ts +204 -0
  55. package/src/tests/session-flow-parity.test.ts +118 -0
  56. package/src/tests/session-state.test.ts +124 -0
  57. package/src/tests/xtrm-finish.test.ts +148 -0
  58. package/src/types/config.ts +51 -0
  59. package/src/types/models.ts +52 -0
  60. package/src/utils/atomic-config.ts +467 -0
  61. package/src/utils/banner.ts +194 -0
  62. package/src/utils/config-adapter.ts +90 -0
  63. package/src/utils/config-injector.ts +81 -0
  64. package/src/utils/env-manager.ts +193 -0
  65. package/src/utils/hash.ts +42 -0
  66. package/src/utils/repo-root.ts +39 -0
  67. package/src/utils/sync-mcp-cli.ts +395 -0
  68. package/src/utils/theme.ts +37 -0
  69. package/src/utils/worktree-session.ts +93 -0
  70. package/test/atomic-config-prune.test.ts +101 -0
  71. package/test/atomic-config.test.ts +138 -0
  72. package/test/clean.test.ts +172 -0
  73. package/test/config-schema.test.ts +52 -0
  74. package/test/context.test.ts +33 -0
  75. package/test/end-worktree.test.ts +168 -0
  76. package/test/extensions/beads.test.ts +166 -0
  77. package/test/extensions/extension-harness.ts +85 -0
  78. package/test/extensions/main-guard.test.ts +77 -0
  79. package/test/extensions/minimal-mode.test.ts +107 -0
  80. package/test/extensions/quality-gates.test.ts +79 -0
  81. package/test/extensions/service-skills.test.ts +84 -0
  82. package/test/extensions/xtrm-loader.test.ts +53 -0
  83. package/test/hooks/quality-check-hooks.test.ts +45 -0
  84. package/test/hooks.test.ts +1075 -0
  85. package/test/install-pi.test.ts +185 -0
  86. package/test/install-project.test.ts +378 -0
  87. package/test/install-service-skills.test.ts +131 -0
  88. package/test/install-surface.test.ts +72 -0
  89. package/test/runtime-subcommands.test.ts +121 -0
  90. package/test/session-launcher.test.ts +139 -0
  91. package/tsconfig.json +22 -0
  92. package/tsup.config.ts +17 -0
  93. package/vitest.config.ts +10 -0
@@ -0,0 +1,51 @@
1
+ import { z } from 'zod';
2
+
3
+ export const SyncModeSchema = z.enum(['copy', 'symlink', 'prune']);
4
+ export type SyncMode = z.infer<typeof SyncModeSchema>;
5
+
6
+ export const TargetConfigSchema = z.object({
7
+ label: z.string(),
8
+ path: z.string(),
9
+ exists: z.boolean(),
10
+ });
11
+ export type TargetConfig = z.infer<typeof TargetConfigSchema>;
12
+
13
+ export const ChangeSetCategorySchema = z.object({
14
+ missing: z.array(z.string()),
15
+ outdated: z.array(z.string()),
16
+ drifted: z.array(z.string()),
17
+ total: z.number(),
18
+ });
19
+ export type ChangeSetCategory = z.infer<typeof ChangeSetCategorySchema>;
20
+
21
+ export const ChangeSetSchema = z.object({
22
+ skills: ChangeSetCategorySchema,
23
+ hooks: ChangeSetCategorySchema,
24
+ config: ChangeSetCategorySchema,
25
+ commands: ChangeSetCategorySchema,
26
+ 'qwen-commands': ChangeSetCategorySchema,
27
+ 'antigravity-workflows': ChangeSetCategorySchema,
28
+ });
29
+ export type ChangeSet = z.infer<typeof ChangeSetSchema>;
30
+
31
+ export const SyncPlanSchema = z.object({
32
+ mode: SyncModeSchema,
33
+ targets: z.array(z.string()),
34
+ });
35
+ export type SyncPlan = z.infer<typeof SyncPlanSchema>;
36
+
37
+ export const ManifestItemSchema = z.object({
38
+ type: z.enum(['skill', 'hook', 'config', 'command']),
39
+ name: z.string(),
40
+ hash: z.string(),
41
+ lastSync: z.string(),
42
+ source: z.string(),
43
+ });
44
+ export type ManifestItem = z.infer<typeof ManifestItemSchema>;
45
+
46
+ export const ManifestSchema = z.object({
47
+ version: z.string().optional().default('1'),
48
+ lastSync: z.string(),
49
+ items: z.number().optional().default(0),
50
+ });
51
+ export type Manifest = z.infer<typeof ManifestSchema>;
@@ -0,0 +1,52 @@
1
+ export interface Skill {
2
+ name: string;
3
+ description: string;
4
+ content: string;
5
+ }
6
+
7
+ export interface Command {
8
+ name: string;
9
+ description: string;
10
+ prompt: string;
11
+ }
12
+
13
+ export interface Hook {
14
+ name?: string;
15
+ type?: string;
16
+ command?: string;
17
+ script?: string;
18
+ timeout?: number;
19
+ events?: string[];
20
+ matcher?: string;
21
+ }
22
+
23
+ export interface MCPServer {
24
+ type?: 'stdio' | 'http' | 'sse';
25
+ command?: string;
26
+ args?: string[];
27
+ env?: Record<string, string>;
28
+ url?: string;
29
+ serverUrl?: string;
30
+ headers?: Record<string, string>;
31
+ disabled?: boolean;
32
+ }
33
+
34
+ export interface SyncOptions {
35
+ dryRun?: boolean;
36
+ yes?: boolean;
37
+ prune?: boolean;
38
+ }
39
+
40
+ export interface ManifestItem {
41
+ type: 'skill' | 'hook' | 'config' | 'command';
42
+ name: string;
43
+ hash: string;
44
+ lastSync: string;
45
+ source: string;
46
+ }
47
+
48
+ export interface Manifest {
49
+ version: string;
50
+ lastSync: string;
51
+ items: Record<string, ManifestItem>;
52
+ }
@@ -0,0 +1,467 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import fs from 'fs-extra';
3
+ // @ts-ignore
4
+ import { parse, stringify } from 'comment-json';
5
+
6
+ /**
7
+ * Atomic Configuration Handler with Vault Pattern
8
+ * Ensures safe read/write operations with protection against corruption during crashes
9
+ */
10
+
11
+ // Protected keys that should never be overwritten if they exist locally
12
+ const PROTECTED_KEYS = [
13
+ 'permissions.allow', // User-defined permissions
14
+ 'hooks.UserPromptSubmit', // Claude hooks
15
+ 'hooks.SessionStart',
16
+ 'hooks.PreToolUse',
17
+ 'hooks.BeforeAgent', // Gemini hooks
18
+ 'hooks.BeforeTool', // Gemini hooks
19
+ 'security', // Auth secrets/OAuth data
20
+ 'general', // Personal preferences
21
+ 'enabledPlugins', // User-enabled/disabled plugins
22
+ 'model', // User's preferred model
23
+ 'skillSuggestions.enabled' // User preferences
24
+ ];
25
+
26
+ /**
27
+ * Check if a key path is exactly protected or a parent of a protected key
28
+ */
29
+ export function isProtectedPath(keyPath: string): boolean {
30
+ return PROTECTED_KEYS.some(protectedPath =>
31
+ keyPath === protectedPath || protectedPath.startsWith(keyPath + '.')
32
+ );
33
+ }
34
+
35
+ /**
36
+ * Check if a key path is a protected key or a child of a protected key
37
+ */
38
+ export function isValueProtected(keyPath: string): boolean {
39
+ return PROTECTED_KEYS.some(protectedPath =>
40
+ keyPath === protectedPath || keyPath.startsWith(protectedPath + '.')
41
+ );
42
+ }
43
+
44
+ function extractHookCommands(wrapper: any): string[] {
45
+ if (!wrapper || !Array.isArray(wrapper.hooks)) return [];
46
+ return wrapper.hooks
47
+ .map((h: any) => h?.command)
48
+ .filter((c: any): c is string => typeof c === 'string' && c.trim().length > 0);
49
+ }
50
+
51
+ function commandKey(command: string): string {
52
+ const m = command.match(/([A-Za-z0-9._-]+\.(?:py|cjs|mjs|js))(?!.*[A-Za-z0-9._-]+\.(?:py|cjs|mjs|js))/);
53
+ return m?.[1] || command.trim();
54
+ }
55
+
56
+ /**
57
+ * Extract script filename from a hook command for pruning purposes.
58
+ */
59
+ function scriptKey(command: string): string | null {
60
+ // Match the script path relative to hooks directory
61
+ // Pattern: /hooks/<optional-subdir>/<filename>.<ext>
62
+ const m = command.match(/\/hooks\/([A-Za-z0-9_/-]+\.(?:py|cjs|mjs|js))/);
63
+ if (m) return m[1];
64
+
65
+ // Fallback: match just the filename if no /hooks/ path
66
+ const m2 = command.match(/([A-Za-z0-9_-]+\.(?:py|cjs|mjs|js))(?!.*[A-Za-z0-9._-]+\.(?:py|cjs|mjs|js))/);
67
+ return m2?.[1] || null;
68
+ }
69
+
70
+ /**
71
+ * Collect all canonical script filenames from incoming hooks.
72
+ */
73
+ function collectCanonicalScripts(incomingHooks: any): Set<string> {
74
+ const scripts = new Set<string>();
75
+ if (!incomingHooks || typeof incomingHooks !== 'object') return scripts;
76
+
77
+ for (const wrappers of Object.values(incomingHooks)) {
78
+ if (!Array.isArray(wrappers)) continue;
79
+ for (const wrapper of wrappers) {
80
+ const commands = extractHookCommands(wrapper);
81
+ for (const cmd of commands) {
82
+ const script = scriptKey(cmd);
83
+ if (script) scripts.add(script);
84
+ }
85
+ }
86
+ }
87
+ return scripts;
88
+ }
89
+
90
+ /**
91
+ * Prune existing hook wrappers that reference scripts NOT in canonical set.
92
+ * Returns { pruned: wrappers[], removed: string[] }
93
+ */
94
+ function pruneStaleWrappers(existing: any[], canonicalScripts: Set<string>): { pruned: any[]; removed: string[] } {
95
+ if (canonicalScripts.size === 0) {
96
+ return { pruned: existing, removed: [] };
97
+ }
98
+
99
+ const removed: string[] = [];
100
+ const pruned: any[] = [];
101
+
102
+ for (const wrapper of existing) {
103
+ if (!Array.isArray(wrapper.hooks)) {
104
+ pruned.push(wrapper);
105
+ continue;
106
+ }
107
+
108
+ const keptHooks: any[] = [];
109
+ for (const hook of wrapper.hooks) {
110
+ const cmd = hook?.command;
111
+ if (typeof cmd !== 'string') {
112
+ keptHooks.push(hook);
113
+ continue;
114
+ }
115
+ // Only prune hooks that are clearly xtrm-managed (have /hooks/ in their path)
116
+ // User-local hooks from other directories are always preserved
117
+ const isXtrmManaged = /\/hooks\//.test(cmd);
118
+ if (!isXtrmManaged) {
119
+ keptHooks.push(hook);
120
+ continue;
121
+ }
122
+ const script = scriptKey(cmd);
123
+ // Keep if: no script (not a file-based hook) OR script is in canonical set
124
+ if (!script || canonicalScripts.has(script)) {
125
+ keptHooks.push(hook);
126
+ } else {
127
+ removed.push(script);
128
+ }
129
+ }
130
+
131
+ if (keptHooks.length > 0) {
132
+ pruned.push({ ...wrapper, hooks: keptHooks });
133
+ }
134
+ }
135
+
136
+ return { pruned, removed };
137
+ }
138
+
139
+ function mergeMatcher(existingMatcher: string, incomingMatcher: string): string {
140
+ const parts = [
141
+ ...existingMatcher.split('|').map((s: string) => s.trim()),
142
+ ...incomingMatcher.split('|').map((s: string) => s.trim()),
143
+ ].filter(Boolean);
144
+ return Array.from(new Set(parts)).join('|');
145
+ }
146
+
147
+ function mergeHookWrappers(existing: any[], incoming: any[]): any[] {
148
+ const merged = existing.map((w: any) => ({ ...w }));
149
+
150
+ for (const incomingWrapper of incoming) {
151
+ const incomingCommands = extractHookCommands(incomingWrapper);
152
+ if (incomingCommands.length === 0) {
153
+ merged.push(incomingWrapper);
154
+ continue;
155
+ }
156
+
157
+ const incomingKeys = new Set(incomingCommands.map(commandKey));
158
+ const incomingTokens = new Set(
159
+ typeof incomingWrapper.matcher === 'string'
160
+ ? incomingWrapper.matcher.split('|').map((s: string) => s.trim()).filter(Boolean)
161
+ : [],
162
+ );
163
+
164
+ const existingIndex = merged.findIndex((existingWrapper: any) => {
165
+ const existingCommands = extractHookCommands(existingWrapper);
166
+ if (!existingCommands.some((c: string) => incomingKeys.has(commandKey(c)))) return false;
167
+
168
+ // Only merge with entries whose matchers overlap (share at least one token).
169
+ // Disjoint matchers (e.g. "Write|Edit" vs "Bash") intentionally serve
170
+ // different purposes and must remain as separate entries.
171
+ if (
172
+ typeof existingWrapper.matcher === 'string' &&
173
+ typeof incomingWrapper.matcher === 'string' &&
174
+ incomingTokens.size > 0
175
+ ) {
176
+ const existingTokens = existingWrapper.matcher
177
+ .split('|').map((s: string) => s.trim()).filter(Boolean);
178
+ const hasOverlap = existingTokens.some((t: string) => incomingTokens.has(t));
179
+ if (!hasOverlap) return false;
180
+ }
181
+
182
+ return true;
183
+ });
184
+
185
+ if (existingIndex === -1) {
186
+ merged.push(incomingWrapper);
187
+ continue;
188
+ }
189
+
190
+ const existingWrapper = merged[existingIndex];
191
+ if (
192
+ typeof existingWrapper.matcher === 'string' &&
193
+ typeof incomingWrapper.matcher === 'string'
194
+ ) {
195
+ existingWrapper.matcher = mergeMatcher(existingWrapper.matcher, incomingWrapper.matcher);
196
+ }
197
+
198
+ if (Array.isArray(existingWrapper.hooks) && Array.isArray(incomingWrapper.hooks)) {
199
+ const existingByKey = new Set(existingWrapper.hooks
200
+ .map((h: any) => h?.command)
201
+ .filter((c: any): c is string => typeof c === 'string')
202
+ .map(commandKey));
203
+ for (const hook of incomingWrapper.hooks) {
204
+ const cmd = hook?.command;
205
+ if (typeof cmd !== 'string' || !existingByKey.has(commandKey(cmd))) {
206
+ existingWrapper.hooks.push(hook);
207
+ }
208
+ }
209
+ }
210
+ }
211
+
212
+ return merged;
213
+ }
214
+
215
+ /**
216
+ * Prune mode: replace xtrm-managed wrappers wholesale with canonical ones.
217
+ * Wrappers whose commands do NOT reference ~/.claude/hooks/ (user-local) are always preserved.
218
+ */
219
+ function replaceHooksObject(existingHooks: any, incomingHooks: any): any {
220
+ const result: any = {};
221
+ const canonicalScripts = collectCanonicalScripts(incomingHooks);
222
+
223
+ // Events only in existing: prune xtrm-managed entries, keep user-local
224
+ for (const [event, existingWrappers] of Object.entries(existingHooks || {})) {
225
+ if (!Array.isArray(existingWrappers)) { result[event] = existingWrappers; continue; }
226
+ if (event in (incomingHooks || {})) continue; // handled below
227
+ const { pruned } = pruneStaleWrappers(existingWrappers, canonicalScripts);
228
+ if (pruned.length > 0) result[event] = pruned;
229
+ }
230
+
231
+ // Events in incoming: use canonical wrappers + preserve user-local wrappers
232
+ for (const [event, incomingWrappers] of Object.entries(incomingHooks || {})) {
233
+ const existingWrappers: any[] = Array.isArray(existingHooks?.[event]) ? existingHooks[event] : [];
234
+ const userLocal = existingWrappers.filter((wrapper: any) => {
235
+ if (!Array.isArray(wrapper.hooks)) return true;
236
+ return wrapper.hooks.every((h: any) => {
237
+ const cmd = h?.command;
238
+ return typeof cmd !== 'string' || !/\/hooks\//.test(cmd);
239
+ });
240
+ });
241
+ result[event] = [...(Array.isArray(incomingWrappers) ? incomingWrappers : []), ...userLocal];
242
+ }
243
+
244
+ return result;
245
+ }
246
+
247
+ function mergeHooksObject(existingHooks: any, incomingHooks: any): any {
248
+ // Step 1: Collect canonical script filenames from incoming hooks
249
+ const canonicalScripts = collectCanonicalScripts(incomingHooks);
250
+
251
+ // Step 2: Prune existing hooks that reference non-canonical scripts
252
+ const result: any = {};
253
+ for (const [event, existingWrappers] of Object.entries(existingHooks || {})) {
254
+ if (!Array.isArray(existingWrappers)) {
255
+ result[event] = existingWrappers;
256
+ continue;
257
+ }
258
+ const { pruned } = pruneStaleWrappers(existingWrappers, canonicalScripts);
259
+ if (pruned.length > 0) {
260
+ result[event] = pruned;
261
+ }
262
+ }
263
+
264
+ // Step 3: Merge incoming hooks with pruned existing hooks
265
+ for (const [event, incomingWrappers] of Object.entries(incomingHooks || {})) {
266
+ const existingWrappers = Array.isArray(result[event]) ? result[event] : [];
267
+ const incomingArray = Array.isArray(incomingWrappers) ? incomingWrappers : [];
268
+ result[event] = mergeHookWrappers(existingWrappers, incomingArray);
269
+ }
270
+ return result;
271
+ }
272
+
273
+ /**
274
+ * Deep merge two objects, preserving protected values from the original
275
+ */
276
+ export function deepMergeWithProtection(original: any, updates: any, currentPath: string = '', opts: { pruneHooks?: boolean } = {}): any {
277
+ const result = { ...original };
278
+
279
+ for (const [key, value] of Object.entries(updates)) {
280
+ const keyPath = currentPath ? `${currentPath}.${key}` : key;
281
+
282
+ // Hooks: merge by command identity normally; in prune mode replace xtrm-managed wrappers wholesale.
283
+ if (
284
+ key === 'hooks' &&
285
+ typeof value === 'object' &&
286
+ value !== null &&
287
+ typeof original[key] === 'object' &&
288
+ original[key] !== null
289
+ ) {
290
+ result[key] = opts.pruneHooks
291
+ ? replaceHooksObject(original[key], value)
292
+ : mergeHooksObject(original[key], value);
293
+ continue;
294
+ }
295
+
296
+ // If this specific value is protected and exists locally, skip it
297
+ if (isValueProtected(keyPath) && original.hasOwnProperty(key)) {
298
+ continue;
299
+ }
300
+
301
+ // Special handling for mcpServers: merge individual server entries
302
+ if (key === 'mcpServers' && typeof value === 'object' && value !== null &&
303
+ typeof original[key] === 'object' && original[key] !== null) {
304
+
305
+ result[key] = { ...original[key] }; // Start with original servers
306
+
307
+ // Add servers from updates that don't exist in original
308
+ for (const [serverName, serverConfig] of Object.entries(value)) {
309
+ if (!result[key].hasOwnProperty(serverName)) {
310
+ result[key][serverName] = serverConfig;
311
+ }
312
+ }
313
+ } else if (
314
+ typeof value === 'object' &&
315
+ value !== null &&
316
+ !Array.isArray(value) &&
317
+ typeof original[key] === 'object' &&
318
+ original[key] !== null &&
319
+ !Array.isArray(original[key])
320
+ ) {
321
+ // Recursively merge nested objects
322
+ result[key] = deepMergeWithProtection(original[key], value, keyPath, opts);
323
+ } else {
324
+ // Overwrite with new value for non-protected keys
325
+ result[key] = value;
326
+ }
327
+ }
328
+
329
+ return result;
330
+ }
331
+
332
+ interface AtomicWriteOptions {
333
+ preserveComments?: boolean;
334
+ backupOnSuccess?: boolean;
335
+ backupSuffix?: string;
336
+ }
337
+
338
+ /**
339
+ * Atomically write data to a file using a temporary file
340
+ */
341
+ export async function atomicWrite(filePath: string, data: any, options: AtomicWriteOptions = {}): Promise<void> {
342
+ const {
343
+ preserveComments = false,
344
+ backupOnSuccess = false,
345
+ backupSuffix = '.bak'
346
+ } = options;
347
+
348
+ const tempFilePath = `${filePath}.tmp.${randomUUID()}`;
349
+
350
+ try {
351
+ let content: string;
352
+ if (preserveComments) {
353
+ content = stringify(data, null, 2);
354
+ } else {
355
+ content = JSON.stringify(data, null, 2);
356
+ }
357
+
358
+ await fs.writeFile(tempFilePath, content, 'utf8');
359
+
360
+ const tempStats = await fs.stat(tempFilePath);
361
+ if (tempStats.size === 0) {
362
+ throw new Error('Temporary file is empty - write failed');
363
+ }
364
+
365
+ if (backupOnSuccess && await fs.pathExists(filePath)) {
366
+ const backupPath = `${filePath}${backupSuffix}`;
367
+ await fs.copy(filePath, backupPath);
368
+ }
369
+
370
+ await fs.rename(tempFilePath, filePath);
371
+ } catch (error) {
372
+ try {
373
+ if (await fs.pathExists(tempFilePath)) {
374
+ await fs.unlink(tempFilePath);
375
+ }
376
+ } catch (cleanupError) { }
377
+ throw error;
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Safely read a JSON configuration file with error handling
383
+ */
384
+ export async function safeReadConfig(filePath: string): Promise<any> {
385
+ try {
386
+ if (!(await fs.pathExists(filePath))) {
387
+ return {};
388
+ }
389
+
390
+ const content = await fs.readFile(filePath, 'utf8');
391
+
392
+ try {
393
+ return parse(content);
394
+ } catch (parseError) {
395
+ return JSON.parse(content);
396
+ }
397
+ } catch (error: any) {
398
+ if (error.code === 'ENOENT') return {};
399
+ throw new Error(`Failed to read config file: ${error.message}`);
400
+ }
401
+ }
402
+
403
+ interface MergeOptions {
404
+ preserveComments?: boolean;
405
+ backupOnSuccess?: boolean;
406
+ dryRun?: boolean;
407
+ resolvedLocalConfig?: any;
408
+ pruneHooks?: boolean;
409
+ }
410
+
411
+ export interface MergeResult {
412
+ updated: boolean;
413
+ changes: string[];
414
+ }
415
+
416
+ /**
417
+ * Perform a safe merge of repository config with local config
418
+ */
419
+ export async function safeMergeConfig(localConfigPath: string, repoConfig: any, options: MergeOptions = {}): Promise<MergeResult> {
420
+ const {
421
+ preserveComments = true,
422
+ backupOnSuccess = true,
423
+ dryRun = false,
424
+ resolvedLocalConfig = null,
425
+ pruneHooks = false,
426
+ } = options;
427
+
428
+ const localConfig = resolvedLocalConfig || await safeReadConfig(localConfigPath);
429
+ const changes: string[] = [];
430
+
431
+ if (localConfig.mcpServers && typeof localConfig.mcpServers === 'object') {
432
+ const localServerNames = Object.keys(localConfig.mcpServers);
433
+ if (localServerNames.length > 0) {
434
+ changes.push(`Preserved ${localServerNames.length} local mcpServers: ${localServerNames.join(', ')}`);
435
+ }
436
+ }
437
+
438
+ if (repoConfig.mcpServers && typeof repoConfig.mcpServers === 'object') {
439
+ const repoServerNames = Object.keys(repoConfig.mcpServers);
440
+ const newServerNames = repoServerNames.filter(name =>
441
+ !localConfig.mcpServers || !localConfig.mcpServers.hasOwnProperty(name)
442
+ );
443
+
444
+ if (newServerNames.length > 0) {
445
+ changes.push(`Added ${newServerNames.length} new non-conflicting mcpServers from repository: ${newServerNames.join(', ')}`);
446
+ }
447
+ }
448
+
449
+ const mergedConfig = deepMergeWithProtection(localConfig, repoConfig, '', { pruneHooks });
450
+ const configsAreEqual = JSON.stringify(localConfig) === JSON.stringify(mergedConfig);
451
+
452
+ if (!configsAreEqual && !dryRun) {
453
+ await atomicWrite(localConfigPath, mergedConfig, {
454
+ preserveComments,
455
+ backupOnSuccess
456
+ });
457
+ }
458
+
459
+ return {
460
+ updated: !configsAreEqual,
461
+ changes
462
+ };
463
+ }
464
+
465
+ export function getProtectedKeys(): string[] {
466
+ return [...PROTECTED_KEYS];
467
+ }