@cachly-dev/mcp-server 0.10.25 → 0.10.27

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.
@@ -4,13 +4,13 @@ import { calculateConfidence, confidenceBadge, CONFIDENCE_STALE_VALUE, CONFIDENC
4
4
  import { ckgSlug, ckgUpdateEdge } from '../ckg.js';
5
5
  import type { CKGEdge, CKGNode } from '../ckg.js';
6
6
  import { keywordSearch } from '../search.js';
7
- import { safeJsonParse } from '../utils.js';
8
7
 
9
8
  type GetConnection = (instanceId: string) => Promise<Redis>;
10
9
  type ApiFetch = <T>(path: string, options?: RequestInit) => Promise<T>;
11
10
 
12
11
  export const ADVANCED_TOOL_NAMES = new Set([
13
12
  'memory_consolidate', 'brain_diff', 'causal_trace', 'knowledge_decay', 'autopilot',
13
+ 'brain_stats', 'pin_lesson', 'unpin_lesson',
14
14
  ]);
15
15
 
16
16
  export async function handleAdvancedTool(
@@ -46,8 +46,7 @@ export async function handleAdvancedTool(
46
46
  for (const k of lessonKeys) {
47
47
  const raw = await redis.get(k);
48
48
  if (!raw) continue;
49
- const lesson = safeJsonParse<Lesson | null>(raw, null);
50
- if (lesson) lessons.set(k, lesson);
49
+ try { lessons.set(k, JSON.parse(raw) as Lesson); } catch { /* skip malformed */ }
51
50
  }
52
51
 
53
52
  // Detect duplicates: same topic prefix (e.g. deploy:api vs deploy:api-v2)
@@ -105,6 +104,25 @@ export async function handleAdvancedTool(
105
104
  for (const k of stale) {
106
105
  await redis.expire(k, 86400 * 30);
107
106
  }
107
+ // Auto-unpin lessons whose lesson key was deleted (merged or stale-expired)
108
+ // Also unpin if the lesson outcome changed to 'failure' (auto-pin only fires on success/partial)
109
+ const pinKeys: string[] = [];
110
+ const pinStream = redis.scanStream({ match: 'cachly:lesson:pin:*', count: 100 });
111
+ await new Promise<void>((res, rej) => { pinStream.on('data', (b: string[]) => pinKeys.push(...b)); pinStream.on('end', res); pinStream.on('error', rej); });
112
+ let unpinned = 0;
113
+ for (const pk of pinKeys) {
114
+ const pinnedTopic = pk.replace('cachly:lesson:pin:', '');
115
+ const lessonStillExists = await redis.exists(`cachly:lesson:best:${pinnedTopic}`);
116
+ if (!lessonStillExists) { await redis.del(pk); unpinned++; continue; }
117
+ const raw = await redis.get(`cachly:lesson:best:${pinnedTopic}`);
118
+ if (raw) {
119
+ try {
120
+ const l = JSON.parse(raw) as { outcome: string };
121
+ if (l.outcome === 'failure') { await redis.del(pk); unpinned++; }
122
+ } catch { /* skip */ }
123
+ }
124
+ }
125
+ if (unpinned > 0) stale.push(...Array(unpinned).fill('__unpin_placeholder__')); // just for count
108
126
  }
109
127
 
110
128
  const lines = [
@@ -168,9 +186,8 @@ export async function handleAdvancedTool(
168
186
  for (const k of lessonKeys) {
169
187
  const raw = await redis.get(k);
170
188
  if (!raw) continue;
171
- {
172
- const l = safeJsonParse<Lesson | null>(raw, null);
173
- if (!l) continue;
189
+ try {
190
+ const l = JSON.parse(raw) as Lesson;
174
191
  const ts = new Date(l.ts).getTime();
175
192
  if (ts >= sinceMs) {
176
193
  // Check history to determine add vs update
@@ -184,7 +201,7 @@ export async function handleAdvancedTool(
184
201
  // lessons with recalls as "active"
185
202
  recalled.push(l);
186
203
  }
187
- }
204
+ } catch { /* skip */ }
188
205
  }
189
206
 
190
207
  const sinceLabel = match ? `last ${since}` : new Date(sinceMs).toLocaleDateString('de-DE');
@@ -218,10 +235,10 @@ export async function handleAdvancedTool(
218
235
  for (const rk of resolutionKeys) {
219
236
  const rr = await redis.get(rk);
220
237
  if (!rr) continue;
221
- {
222
- const rn = safeJsonParse<{ topic?: string; resolution?: string; ts?: string } | null>(rr, null);
223
- if (rn?.ts && new Date(rn.ts).getTime() >= sinceMs) recentResolutions.push({ topic: rn.topic ?? rk, resolution: rn.resolution ?? 'unknown', ts: rn.ts });
224
- }
238
+ try {
239
+ const rn = JSON.parse(rr) as { topic?: string; resolution?: string; ts?: string };
240
+ if (rn.ts && new Date(rn.ts).getTime() >= sinceMs) recentResolutions.push({ topic: rn.topic ?? rk, resolution: rn.resolution ?? 'unknown', ts: rn.ts });
241
+ } catch { /* skip */ }
225
242
  }
226
243
  if (recentResolutions.length > 0) {
227
244
  lines.push(`🗳️ **MADC Resolutions** (${recentResolutions.length} contested beliefs resolved):`);
@@ -241,10 +258,8 @@ export async function handleAdvancedTool(
241
258
 
242
259
  // 3. FedBrain transfers received in window
243
260
  const fedHistRaw = await redis.lrange('cachly:fedbrain:federations', -20, -1);
244
- type FedEntry = { source: string; domain: string; transferred_at: string; nodes: number; edges: number };
245
- const recentFeds = fedHistRaw
246
- .map(r => safeJsonParse<FedEntry | null>(r, null))
247
- .filter((f): f is FedEntry => f !== null && new Date(f.transferred_at).getTime() >= sinceMs);
261
+ const recentFeds = fedHistRaw.map(r => { try { return JSON.parse(r) as { source: string; domain: string; transferred_at: string; nodes: number; edges: number }; } catch { return null; } })
262
+ .filter(f => f && new Date(f!.transferred_at).getTime() >= sinceMs) as Array<{ source: string; domain: string; transferred_at: string; nodes: number; edges: number }>;
248
263
  if (recentFeds.length > 0) {
249
264
  lines.push(`🧠 **FedBrain transfers received (${recentFeds.length}):**`);
250
265
  for (const f of recentFeds.slice(0, 3)) {
@@ -287,30 +302,27 @@ export async function handleAdvancedTool(
287
302
  for (const nodeKey of nodeKeys.slice(0, 10)) {
288
303
  const nodeRaw = await redis.get(nodeKey);
289
304
  if (!nodeRaw) continue;
290
- const node = safeJsonParse<CKGNode | null>(nodeRaw, null);
291
- if (!node) continue;
305
+ const node: CKGNode = JSON.parse(nodeRaw);
292
306
  // Get edges from this node
293
307
  const edgeKeys = await redis.smembers(`cachly:ckg:idx:from:${node.id}`);
294
308
  for (const ek of edgeKeys.slice(0, 20)) {
295
309
  const edgeRaw = await redis.get(ek);
296
310
  if (!edgeRaw) continue;
297
- const edge = safeJsonParse<CKGEdge | null>(edgeRaw, null);
298
- if (!edge) continue;
311
+ const edge: CKGEdge = JSON.parse(edgeRaw);
299
312
  if (edge.edgeType !== 'fixes' && edge.edgeType !== 'requires') continue;
300
313
  // Find lesson for this concept
301
314
  const lessonRaw = await redis.get(`cachly:lesson:best:${edge.from.replace(/-/g, ':').replace(/^fix:/, 'fix:')}`);
302
- const lesson = lessonRaw ? safeJsonParse<{ topic: string; what_worked?: string; ts: string; outcome: string; recall_count?: number; severity?: string } | null>(lessonRaw, null) ?? undefined : undefined;
303
- ckgResults.push({ conceptId: node.id, edge, lesson: lesson ?? undefined });
315
+ const lesson = lessonRaw ? JSON.parse(lessonRaw) : undefined;
316
+ ckgResults.push({ conceptId: node.id, edge, lesson });
304
317
  }
305
318
  }
306
319
  for (const ek of [...fromKeys, ...toKeys].slice(0, 20)) {
307
320
  const edgeRaw = await redis.get(ek);
308
321
  if (!edgeRaw) continue;
309
- const edge = safeJsonParse<CKGEdge | null>(edgeRaw, null);
310
- if (!edge) continue;
322
+ const edge: CKGEdge = JSON.parse(edgeRaw);
311
323
  const lessonRaw = await redis.get(`cachly:lesson:best:${edge.from}`);
312
- const lesson = lessonRaw ? safeJsonParse<{ topic: string; what_worked?: string; ts: string; outcome: string; recall_count?: number; severity?: string } | null>(lessonRaw, null) ?? undefined : undefined;
313
- ckgResults.push({ conceptId: edge.from, edge, lesson: lesson ?? undefined });
324
+ const lesson = lessonRaw ? JSON.parse(lessonRaw) : undefined;
325
+ ckgResults.push({ conceptId: edge.from, edge, lesson });
314
326
  }
315
327
  }
316
328
  } catch { /* CKG traversal non-critical */ }
@@ -344,15 +356,14 @@ export async function handleAdvancedTool(
344
356
  for (const k of lessonKeys) {
345
357
  const raw = await redis.get(k);
346
358
  if (!raw) continue;
347
- {
348
- const l = safeJsonParse<Lesson | null>(raw, null);
349
- if (!l) continue;
359
+ try {
360
+ const l = JSON.parse(raw) as Lesson;
350
361
  if (filterTags.length > 0 && !(l.tags ?? []).some((t: string) => filterTags.includes(t))) continue;
351
362
  const haystack = [l.topic, l.what_failed ?? '', l.what_worked ?? '', l.context ?? '']
352
363
  .join(' ').toLowerCase();
353
364
  const score = tokens.reduce((s, t) => s + (haystack.includes(t) ? 1 : 0), 0);
354
365
  if (score > 0) scored.push({ score, lesson: l, key: k });
355
- }
366
+ } catch { /* skip */ }
356
367
  }
357
368
  scored.sort((a, b) => b.score - a.score);
358
369
  const chain = scored.slice(0, max_depth);
@@ -429,10 +440,6 @@ export async function handleAdvancedTool(
429
440
  lines.push(topSolution.what_worked.slice(0, 500));
430
441
  lines.push(`\`\`\``);
431
442
  lines.push('');
432
-
433
- // Track estimated time saved — causal_trace finding a known fix is worth 1-4h
434
- const savedMins = topSolution.severity === 'critical' ? 240 : topSolution.severity === 'major' ? 120 : 60;
435
- redis.incrbyfloat(`cachly:stats:time_saved_mins:${instance_id}`, savedMins).catch(() => {});
436
443
  }
437
444
  }
438
445
 
@@ -465,9 +472,8 @@ export async function handleAdvancedTool(
465
472
  for (const k of lessonKeys) {
466
473
  const raw = await redis.get(k);
467
474
  if (!raw) continue;
468
- {
469
- const l = safeJsonParse<Lesson | null>(raw, null);
470
- if (!l) continue;
475
+ try {
476
+ const l = JSON.parse(raw) as Lesson;
471
477
  const ageMs = now - new Date(l.ts).getTime();
472
478
  if (ageMs < minAgeMs) continue;
473
479
  const age_days = Math.floor(ageMs / 86400000);
@@ -484,7 +490,7 @@ export async function handleAdvancedTool(
484
490
  const confidence = Math.min(100, Math.max(0, base + recallBoost + outcomePenalty));
485
491
 
486
492
  scores.push({ topic: l.topic, confidence, age_days, recalls, outcome: l.outcome });
487
- }
493
+ } catch { /* skip */ }
488
494
  }
489
495
 
490
496
  // Sort by lowest confidence first
@@ -541,6 +547,12 @@ export async function handleAdvancedTool(
541
547
  - **Window opens** → call \`session_start(instance_id="${instance_id}", focus="<today's goal>")\`
542
548
  - **Bug fixed / deployed / discovered** → call \`learn_from_attempts(instance_id="${instance_id}", topic="fix:<area>", outcome="success", what_worked="...")\`
543
549
  - **Window closes** → call \`session_end(instance_id="${instance_id}", summary="...", files_changed=[...])\`
550
+
551
+ ### New team member setup:
552
+ \`\`\`bash
553
+ npx @cachly-dev/init
554
+ \`\`\`
555
+ Run once — auto-detects all AI editors and gives instant full context.
544
556
  `;
545
557
 
546
558
  const full = `## Cachly AI Brain — Autopilot for ${project_name}
@@ -612,9 +624,22 @@ smart_recall(
612
624
 
613
625
  ---
614
626
 
627
+ ## 👋 New team member?
628
+
629
+ \`\`\`bash
630
+ npx @cachly-dev/init
631
+ \`\`\`
632
+
633
+ Run once — auto-detects Claude Code, Cursor, Copilot, Windsurf, Continue and writes all MCP configs. Free, no credit card.
634
+
635
+ Demo: [cachly.dev](https://cachly.dev)
636
+
637
+ ---
638
+
615
639
  *Cachly v0.6 · Generated ${new Date().toISOString().slice(0, 10)}*
616
640
  `;
617
641
 
642
+
618
643
  const content = style === 'minimal' ? minimal : full;
619
644
  const filename = editor === 'copilot'
620
645
  ? '.github/copilot-instructions.md'
@@ -643,6 +668,161 @@ smart_recall(
643
668
  ].join('\n');
644
669
  }
645
670
 
671
+ // ── brain_stats ─────────────────────────────────────────────────────────
672
+ case 'brain_stats': {
673
+ const { instance_id } = args as { instance_id: string };
674
+ const redis = await getConnection(instance_id);
675
+
676
+ // Lessons
677
+ const lessonKeys: string[] = [];
678
+ const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
679
+ await new Promise<void>((res, rej) => { lStream.on('data', (b: string[]) => lessonKeys.push(...b)); lStream.on('end', res); lStream.on('error', rej); });
680
+
681
+ type LessonMeta = { topic: string; outcome: string; ts: string; severity?: string; recall_count?: number };
682
+ const lessons: LessonMeta[] = [];
683
+ if (lessonKeys.length > 0) {
684
+ const raws = await redis.mget(...lessonKeys);
685
+ for (const r of raws) {
686
+ if (!r) continue;
687
+ try { lessons.push(JSON.parse(r) as LessonMeta); } catch { /* skip */ }
688
+ }
689
+ }
690
+
691
+ // Context entries
692
+ let ctxCount = 0;
693
+ const ctxStream = redis.scanStream({ match: 'cachly:ctx:*', count: 200 });
694
+ await new Promise<void>((res, rej) => {
695
+ ctxStream.on('data', (b: string[]) => { ctxCount += (b as string[]).filter(k => !k.endsWith(':meta')).length; });
696
+ ctxStream.on('end', res); ctxStream.on('error', rej);
697
+ });
698
+
699
+ // Roadmap items
700
+ let roadmapOpen = 0; let roadmapDone = 0;
701
+ try {
702
+ const rm = await redis.hgetall(`cachly:roadmap:${instance_id}`);
703
+ for (const v of Object.values(rm ?? {})) {
704
+ try {
705
+ const item = JSON.parse(v as string) as { status: string };
706
+ if (item.status === 'done') roadmapDone++;
707
+ else if (['planned', 'in-progress', 'blocked'].includes(item.status)) roadmapOpen++;
708
+ } catch { /* skip */ }
709
+ }
710
+ } catch { /* non-critical */ }
711
+
712
+ // Pinned lessons
713
+ const pinKeys: string[] = [];
714
+ const pStream = redis.scanStream({ match: 'cachly:lesson:pin:*', count: 100 });
715
+ await new Promise<void>((res, rej) => { pStream.on('data', (b: string[]) => pinKeys.push(...b)); pStream.on('end', res); pStream.on('error', rej); });
716
+ const pinnedTopics = pinKeys.map(k => k.replace('cachly:lesson:pin:', ''));
717
+
718
+ // Streak
719
+ let streakDays = 0;
720
+ try {
721
+ const sr = await redis.get('cachly:streak:current');
722
+ if (sr) streakDays = (JSON.parse(sr) as { days: number }).days;
723
+ } catch { /* skip */ }
724
+
725
+ // Last session
726
+ let lastSessionSummary = 'none yet';
727
+ let lastSessionAgo = '';
728
+ try {
729
+ const lsr = await redis.get('cachly:session:last');
730
+ if (lsr) {
731
+ const ls = JSON.parse(lsr) as { summary: string; ts: string };
732
+ const ago = Math.round((Date.now() - new Date(ls.ts).getTime()) / 60000);
733
+ lastSessionAgo = ago < 60 ? `${ago}m ago` : ago < 1440 ? `${Math.round(ago / 60)}h ago` : `${Math.round(ago / 1440)}d ago`;
734
+ lastSessionSummary = ls.summary;
735
+ }
736
+ } catch { /* skip */ }
737
+
738
+ // Crystal
739
+ let crystalLabel = '';
740
+ try {
741
+ const cr = await redis.get('cachly:crystal:latest');
742
+ if (cr) crystalLabel = (JSON.parse(cr) as { label: string }).label;
743
+ } catch { /* skip */ }
744
+
745
+ // Top topics
746
+ const topicFreq = new Map<string, number>();
747
+ for (const l of lessons) {
748
+ const cat = l.topic.split(':')[0] ?? 'other';
749
+ topicFreq.set(cat, (topicFreq.get(cat) ?? 0) + 1);
750
+ }
751
+ const topTopics = [...topicFreq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
752
+
753
+ // Outcomes
754
+ const successCount = lessons.filter(l => l.outcome === 'success').length;
755
+ const failCount = lessons.filter(l => l.outcome === 'failure').length;
756
+ const totalRecalls = lessons.reduce((s, l) => s + (l.recall_count ?? 0), 0);
757
+
758
+ const lines: string[] = [
759
+ '🧠 **Brain Stats**',
760
+ '',
761
+ `📚 **Lessons:** ${lessons.length} (✅ ${successCount} success · ❌ ${failCount} open · recalled ${totalRecalls}×)`,
762
+ `📁 **Context:** ${ctxCount} entries`,
763
+ `🗺️ **Roadmap:** ${roadmapOpen} open · ${roadmapDone} done`,
764
+ `🔥 **Streak:** ${streakDays > 0 ? `${streakDays} day${streakDays > 1 ? 's' : ''}` : 'no streak yet'}`,
765
+ '',
766
+ ];
767
+
768
+ if (pinnedTopics.length > 0) {
769
+ lines.push(`📌 **Pinned lessons (${pinnedTopics.length}):** ${pinnedTopics.map(t => `\`${t}\``).join(' · ')}`);
770
+ lines.push('');
771
+ }
772
+
773
+ if (topTopics.length > 0) {
774
+ lines.push('📊 **Top knowledge domains:**');
775
+ for (const [cat, count] of topTopics) {
776
+ lines.push(` · ${cat.padEnd(20)} ${count} lesson${count > 1 ? 's' : ''}`);
777
+ }
778
+ lines.push('');
779
+ }
780
+
781
+ if (lastSessionSummary !== 'none yet') {
782
+ lines.push(`📅 **Last session** (${lastSessionAgo}): ${lastSessionSummary}`);
783
+ lines.push('');
784
+ }
785
+ if (crystalLabel) {
786
+ lines.push(`💎 **Memory Crystal:** ${crystalLabel}`);
787
+ lines.push('');
788
+ }
789
+
790
+ lines.push('💡 `brain_doctor` for health recommendations · `brain_diff(since="7d")` for what changed');
791
+ return lines.join('\n');
792
+ }
793
+
794
+ // ── pin_lesson ──────────────────────────────────────────────────────────
795
+ case 'pin_lesson': {
796
+ const { instance_id, topic } = args as { instance_id: string; topic: string };
797
+ const redis = await getConnection(instance_id);
798
+
799
+ // Verify the lesson exists
800
+ const lessonRaw = await redis.get(`cachly:lesson:best:${topic}`);
801
+ if (!lessonRaw) {
802
+ return `❌ No lesson found for topic \`${topic}\`.\n\nFirst create it with \`learn_from_attempts(topic="${topic}", ...)\`.`;
803
+ }
804
+
805
+ await redis.set(`cachly:lesson:pin:${topic}`, '1');
806
+ return [
807
+ `📌 **Pinned** \`${topic}\``,
808
+ '',
809
+ 'This lesson will now appear at the top of every `session_start` briefing, regardless of age.',
810
+ '',
811
+ `Remove with: \`unpin_lesson(instance_id="...", topic="${topic}")\``,
812
+ ].join('\n');
813
+ }
814
+
815
+ // ── unpin_lesson ─────────────────────────────────────────────────────────
816
+ case 'unpin_lesson': {
817
+ const { instance_id, topic } = args as { instance_id: string; topic: string };
818
+ const redis = await getConnection(instance_id);
819
+ const deleted = await redis.del(`cachly:lesson:pin:${topic}`);
820
+ if (deleted === 0) {
821
+ return `ℹ️ \`${topic}\` was not pinned. Use \`brain_stats\` to see all pinned lessons.`;
822
+ }
823
+ return `📌 Unpinned \`${topic}\`. It will now follow normal recency/confidence decay in session_start.`;
824
+ }
825
+
646
826
  // ── v0.7 Knowledge Syndication: syndicate ────────────────────────────────
647
827
 
648
828
  default:
package/src/index.ts CHANGED
@@ -873,6 +873,124 @@ function buildMcpConfig(apiKey: string, instanceId: string, editor: string): str
873
873
  }, null, 2);
874
874
  }
875
875
 
876
+ /**
877
+ * Build only the per-server entry that gets merged into an existing config.
878
+ * Returned shape varies by editor — see writeMergedMcpConfig for the merge
879
+ * locations.
880
+ */
881
+ function buildCachlyServerEntry(apiKey: string, instanceId: string, editor: string): unknown {
882
+ if (editor === 'continue') {
883
+ return {
884
+ transport: { type: 'stdio', command: 'npx', args: ['-y', '@cachly-dev/mcp-server@latest'] },
885
+ env: { CACHLY_API_URL: 'https://api.cachly.dev', CACHLY_JWT: apiKey, CACHLY_BRAIN_INSTANCE_ID: instanceId },
886
+ };
887
+ }
888
+ if (editor === 'zed') {
889
+ return {
890
+ command: {
891
+ path: 'npx',
892
+ args: ['-y', '@cachly-dev/mcp-server@latest'],
893
+ env: { CACHLY_API_URL: 'https://api.cachly.dev', CACHLY_JWT: apiKey, CACHLY_BRAIN_INSTANCE_ID: instanceId },
894
+ },
895
+ settings: {},
896
+ };
897
+ }
898
+ return {
899
+ command: 'npx',
900
+ args: ['-y', '@cachly-dev/mcp-server@latest'],
901
+ env: { CACHLY_API_URL: 'https://api.cachly.dev', CACHLY_JWT: apiKey, CACHLY_BRAIN_INSTANCE_ID: instanceId },
902
+ };
903
+ }
904
+
905
+ /**
906
+ * Merge the cachly server into an existing editor MCP config file — preserving
907
+ * every other MCP server the user has configured. Creates the file if missing.
908
+ * Writes a one-time .cachly-backup-<timestamp> next to the file before any
909
+ * mutation so the user can recover from corrupt/unexpected merges.
910
+ *
911
+ * Returns 'created' if the file did not exist, 'updated' if cachly was already
912
+ * present (re-pointed to a possibly new instance), or 'merged' if cachly was
913
+ * added alongside other servers.
914
+ */
915
+ async function writeMergedMcpConfig(
916
+ configPath: string,
917
+ apiKey: string,
918
+ instanceId: string,
919
+ editor: string,
920
+ ): Promise<'created' | 'updated' | 'merged'> {
921
+ const { writeFile, readFile, mkdir, copyFile } = await import('node:fs/promises');
922
+ const { existsSync } = await import('node:fs');
923
+ const { dirname } = await import('node:path');
924
+
925
+ await mkdir(dirname(configPath), { recursive: true });
926
+
927
+ // No existing file → write fresh full config.
928
+ if (!existsSync(configPath)) {
929
+ await writeFile(configPath, buildMcpConfig(apiKey, instanceId, editor), 'utf-8');
930
+ return 'created';
931
+ }
932
+
933
+ // Parse existing config. On parse failure, back it up and start fresh
934
+ // (never silently destroy user data — the .cachly-backup gives them a
935
+ // recovery path).
936
+ const raw = await readFile(configPath, 'utf-8');
937
+ let existing: Record<string, unknown>;
938
+ try {
939
+ existing = JSON.parse(raw) as Record<string, unknown>;
940
+ if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
941
+ throw new Error('config root is not an object');
942
+ }
943
+ } catch {
944
+ const backupPath = `${configPath}.cachly-backup-${Date.now()}`;
945
+ await copyFile(configPath, backupPath);
946
+ await writeFile(configPath, buildMcpConfig(apiKey, instanceId, editor), 'utf-8');
947
+ return 'created';
948
+ }
949
+
950
+ // Defensive backup of the valid config before mutation — first time only.
951
+ // The cachly-backup file is overwritten on each re-init so we don't pollute
952
+ // the project, but it gives the user one undo level.
953
+ const backupPath = `${configPath}.cachly-backup`;
954
+ try {
955
+ await copyFile(configPath, backupPath);
956
+ } catch { /* non-critical — backup is best-effort */ }
957
+
958
+ const entry = buildCachlyServerEntry(apiKey, instanceId, editor);
959
+ let hadCachly = false;
960
+
961
+ if (editor === 'continue') {
962
+ // experimental.modelContextProtocolServers is an Array — replace existing
963
+ // cachly entry (matched by command/args containing @cachly-dev/mcp-server)
964
+ // or append.
965
+ type ContinueServer = { transport?: { args?: string[] }; env?: Record<string, string> };
966
+ const exp = (existing.experimental as Record<string, unknown> | undefined) ?? {};
967
+ const servers = Array.isArray(exp.modelContextProtocolServers)
968
+ ? (exp.modelContextProtocolServers as ContinueServer[])
969
+ : [];
970
+ const isCachly = (s: ContinueServer) =>
971
+ s?.transport?.args?.some((a) => typeof a === 'string' && a.includes('@cachly-dev/mcp-server')) ?? false;
972
+ const filtered = servers.filter((s) => {
973
+ if (isCachly(s)) { hadCachly = true; return false; }
974
+ return true;
975
+ });
976
+ filtered.push(entry as ContinueServer);
977
+ existing.experimental = { ...exp, modelContextProtocolServers: filtered };
978
+ } else if (editor === 'zed') {
979
+ // context_servers is a Record — set cachly key.
980
+ const cs = (existing.context_servers as Record<string, unknown> | undefined) ?? {};
981
+ hadCachly = 'cachly' in cs;
982
+ existing.context_servers = { ...cs, cachly: entry };
983
+ } else {
984
+ // Standard mcpServers map — Claude / Cursor / Windsurf / VS Code / Cline.
985
+ const mcp = (existing.mcpServers as Record<string, unknown> | undefined) ?? {};
986
+ hadCachly = 'cachly' in mcp;
987
+ existing.mcpServers = { ...mcp, cachly: entry };
988
+ }
989
+
990
+ await writeFile(configPath, JSON.stringify(existing, null, 2), 'utf-8');
991
+ return hadCachly ? 'updated' : 'merged';
992
+ }
993
+
876
994
  function buildClaudeMdBlock(instanceId: string): string {
877
995
  return `${CLAUDE_MD_MARKER_START}
878
996
  ## Cachly AI Brain — Always Active
@@ -969,9 +1087,11 @@ if (process.argv[2] === 'init') {
969
1087
 
970
1088
  const configFile = EDITOR_FILES[editor] ?? '.mcp.json';
971
1089
  const configPath = resolve(projectDir, configFile);
972
- await mkdir(dirname(configPath), { recursive: true });
973
- await writeFile(configPath, buildMcpConfig(apiKey, instanceId, editor), 'utf-8');
974
- console.log(`\n✅ Written: ${configFile}`);
1090
+ const mergeResult = await writeMergedMcpConfig(configPath, apiKey, instanceId, editor);
1091
+ const verb = mergeResult === 'created' ? '✅ Written'
1092
+ : mergeResult === 'updated' ? '✅ Updated (kept existing servers)'
1093
+ : '✅ Merged into existing config (other servers untouched)';
1094
+ console.log(`\n${verb}: ${configFile}`);
975
1095
 
976
1096
  // Always write CLAUDE.md (idempotent — safe to run multiple times)
977
1097
  const result = await writeClaudeMd(projectDir, instanceId);
@@ -1252,12 +1372,18 @@ if (process.argv[2] === 'setup') {
1252
1372
  console.log(`Step 3: Configuring for: ${editorsToSetup.map(editorLabel).join(', ')}\n`);
1253
1373
 
1254
1374
  // ── Step 4: Write editor configs (project-level) ─────────────────────────
1375
+ // Critical: NEVER overwrite existing configs blindly — merge cachly into the
1376
+ // existing mcpServers map so the user's other MCP servers (filesystem,
1377
+ // github, postgres, …) are preserved. writeMergedMcpConfig also drops a
1378
+ // .cachly-backup file next to the original for one-step recovery.
1255
1379
  for (const editor of editorsToSetup) {
1256
1380
  const configFile = EDITOR_FILES[editor] ?? '.mcp.json';
1257
1381
  const configPath = resolve(cwd, configFile);
1258
- await mkdir(dirname(configPath), { recursive: true });
1259
- await writeFile(configPath, buildMcpConfig(token, instance.id, editor), 'utf-8');
1260
- console.log(`✅ Written: ${configFile}`);
1382
+ const result = await writeMergedMcpConfig(configPath, token, instance.id, editor);
1383
+ const verb = result === 'created' ? '✅ Written'
1384
+ : result === 'updated' ? '✅ Updated (kept existing servers)'
1385
+ : '✅ Merged (other servers untouched)';
1386
+ console.log(`${verb}: ${configFile}`);
1261
1387
  }
1262
1388
 
1263
1389
  // ── Step 4b: Write global Claude Code config (~/.claude/mcp.json) ─────────