@cachly-dev/mcp-server 0.10.27 → 0.10.40

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 (79) hide show
  1. package/.env.test.example +23 -0
  2. package/.github/ISSUE_TEMPLATE/bug_report.md +50 -0
  3. package/.github/ISSUE_TEMPLATE/config.yml +8 -0
  4. package/.github/ISSUE_TEMPLATE/feature_request.md +25 -0
  5. package/.github/workflows/publish.yml +72 -0
  6. package/CHANGELOG.md +252 -0
  7. package/LAUNCH_TEXTS.md +182 -0
  8. package/LICENSE +202 -0
  9. package/README.md +142 -116
  10. package/dist/__tests__/auth.test.d.ts +1 -0
  11. package/dist/__tests__/auth.test.js +107 -0
  12. package/dist/__tests__/auth.test.js.map +1 -0
  13. package/dist/__tests__/e2e/api-auth.test.d.ts +7 -0
  14. package/dist/__tests__/e2e/api-auth.test.js +162 -0
  15. package/dist/__tests__/e2e/api-auth.test.js.map +1 -0
  16. package/dist/__tests__/e2e/api-health.test.d.ts +7 -0
  17. package/dist/__tests__/e2e/api-health.test.js +95 -0
  18. package/dist/__tests__/e2e/api-health.test.js.map +1 -0
  19. package/dist/__tests__/e2e/cli.test.d.ts +10 -0
  20. package/dist/__tests__/e2e/cli.test.js +204 -0
  21. package/dist/__tests__/e2e/cli.test.js.map +1 -0
  22. package/dist/__tests__/e2e/config.d.ts +35 -0
  23. package/dist/__tests__/e2e/config.js +53 -0
  24. package/dist/__tests__/e2e/config.js.map +1 -0
  25. package/dist/__tests__/e2e/funnel.test.d.ts +13 -0
  26. package/dist/__tests__/e2e/funnel.test.js +90 -0
  27. package/dist/__tests__/e2e/funnel.test.js.map +1 -0
  28. package/dist/__tests__/e2e/mcp-tools.test.d.ts +7 -0
  29. package/dist/__tests__/e2e/mcp-tools.test.js +129 -0
  30. package/dist/__tests__/e2e/mcp-tools.test.js.map +1 -0
  31. package/dist/__tests__/e2e/provisioning.test.d.ts +15 -0
  32. package/dist/__tests__/e2e/provisioning.test.js +123 -0
  33. package/dist/__tests__/e2e/provisioning.test.js.map +1 -0
  34. package/dist/handlers/advanced.js +38 -227
  35. package/dist/handlers/advanced.js.map +1 -1
  36. package/dist/handlers/brain.js +119 -82
  37. package/dist/handlers/brain.js.map +1 -1
  38. package/dist/handlers/fedbrain.js +93 -44
  39. package/dist/handlers/fedbrain.js.map +1 -1
  40. package/dist/handlers/roadmap.js +8 -2
  41. package/dist/handlers/roadmap.js.map +1 -1
  42. package/dist/handlers/syndicate.js +15 -7
  43. package/dist/handlers/syndicate.js.map +1 -1
  44. package/dist/handlers/team.js +62 -50
  45. package/dist/handlers/team.js.map +1 -1
  46. package/dist/index.js +1264 -234
  47. package/dist/index.js.map +1 -1
  48. package/dist/tools.d.ts +3 -54
  49. package/dist/tools.js +8 -62
  50. package/dist/tools.js.map +1 -1
  51. package/eslint.config.js +16 -0
  52. package/glama.json +36 -0
  53. package/llms.txt +155 -0
  54. package/package.json +38 -32
  55. package/scripts/e2e-run.sh +160 -0
  56. package/scripts/postinstall.js +12 -12
  57. package/server.json +3 -9
  58. package/smithery.yaml +15 -8
  59. package/src/__tests__/auth.test.ts +147 -0
  60. package/src/__tests__/e2e/api-auth.test.ts +182 -0
  61. package/src/__tests__/e2e/api-health.test.ts +103 -0
  62. package/src/__tests__/e2e/cli.test.ts +228 -0
  63. package/src/__tests__/e2e/config.ts +53 -0
  64. package/src/__tests__/e2e/funnel.test.ts +99 -0
  65. package/src/__tests__/e2e/mcp-tools.test.ts +191 -0
  66. package/src/__tests__/e2e/provisioning.test.ts +128 -0
  67. package/src/handlers/advanced.ts +37 -217
  68. package/src/handlers/brain.ts +118 -78
  69. package/src/handlers/fedbrain.ts +88 -38
  70. package/src/handlers/roadmap.ts +7 -2
  71. package/src/handlers/syndicate.ts +13 -7
  72. package/src/handlers/team.ts +57 -33
  73. package/src/index.ts +1312 -257
  74. package/src/tools.ts +8 -66
  75. package/vitest.config.ts +12 -0
  76. package/dist/__tests__/brain-predict-failures.test.d.ts +0 -17
  77. package/dist/__tests__/brain-predict-failures.test.js +0 -339
  78. package/dist/__tests__/brain-predict-failures.test.js.map +0 -1
  79. package/src/__tests__/brain-predict-failures.test.ts +0 -430
package/dist/index.js CHANGED
@@ -1,5 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { jwtExpiryMs, checkJwt, handleApiError } from './auth.js';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { fileURLToPath } from 'node:url';
5
+ // True only when this file is the entry point (not imported by tests or other modules).
6
+ const _isMain = process.argv[1] != null &&
7
+ (process.argv[1] === fileURLToPath(import.meta.url) ||
8
+ process.argv[1].endsWith('/dist/index.js') ||
9
+ process.argv[1].endsWith('/src/index.ts'));
3
10
  /**
4
11
  * cachly MCP Server v0.4.0
5
12
  *
@@ -42,7 +49,7 @@ import { Redis } from 'ioredis';
42
49
  const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
43
50
  let JWT = process.env.CACHLY_JWT ?? '';
44
51
  const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
45
- const CURRENT_VERSION = '0.10.10';
52
+ const CURRENT_VERSION = '0.10.40';
46
53
  // ── Default Instance Resolution (for Smithery & single-credential setups) ────
47
54
  // When CACHLY_BRAIN_INSTANCE_ID is set, tools can omit the instance_id parameter.
48
55
  // When neither is set, we auto-fetch the first running instance once per process.
@@ -139,6 +146,7 @@ async function pollDeviceFlow(flow) {
139
146
  JWT = apiKey;
140
147
  setEmbedJwt(apiKey); // keep embeddings.ts in sync
141
148
  _deviceFlow = null;
149
+ sendFunnelEvent('device_flow_completed');
142
150
  // Auto-provision: find or create the user's brain instance.
143
151
  _defaultInstanceLastAttempt = 0;
144
152
  await resolveDefaultInstanceId();
@@ -155,8 +163,17 @@ async function pollDeviceFlow(flow) {
155
163
  if (id)
156
164
  _defaultInstanceId = id;
157
165
  }
166
+ else {
167
+ // Log so telemetry can detect free-tier auto-provision failures
168
+ void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
169
+ method: 'POST',
170
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${JWT}` },
171
+ body: JSON.stringify({ event: 'auto_provision_failed', status: autoRes.status, version: CURRENT_VERSION }),
172
+ signal: AbortSignal.timeout(3000),
173
+ }).catch(() => { });
174
+ }
158
175
  }
159
- catch { /* non-fatal */ }
176
+ catch { /* non-fatal — server unreachable, getConnection will surface the error */ }
160
177
  }
161
178
  // Give a provisioning instance a head-start so getConnection's wait is shorter.
162
179
  // The instance is almost always ready by the time the user's tool re-enters.
@@ -237,11 +254,28 @@ async function getConnection(instance_id) {
237
254
  body: JSON.stringify({ event: 'instance_not_reachable', instance_id, status: inst.status, version: CURRENT_VERSION }),
238
255
  signal: AbortSignal.timeout(3000),
239
256
  }).catch(() => { });
240
- const hint = inst.status === 'provisioning'
241
- ? `⏳ Brain instance "${inst.name}" is still starting up.\n\nThis usually finishes within 30 seconds. Please try again in a moment.`
242
- : `Brain instance "${inst.name}" is not reachable (status: ${inst.status}).\n\n` +
257
+ let hint;
258
+ if (inst.status === 'provisioning') {
259
+ hint = `⏳ Brain instance "${inst.name}" is still starting up.\n\nFirst-time provisioning typically takes 1–3 minutes. Please retry in a moment — the instance will be ready soon.`;
260
+ }
261
+ else if (inst.status === 'failed') {
262
+ hint = `❌ Brain instance "${inst.name}" failed to start.\n\n` +
263
+ `Our system will retry automatically. Check status at: https://cachly.dev/instances\n` +
264
+ `If this persists, contact support@cachly.dev.`;
265
+ }
266
+ else if (inst.status === 'suspended') {
267
+ hint = `⏸ Brain instance "${inst.name}" is suspended (billing issue).\n\n` +
268
+ `Update your payment method at: https://cachly.dev/billing`;
269
+ }
270
+ else if (inst.status === 'pending_payment') {
271
+ hint = `💳 Brain instance "${inst.name}" is waiting for payment.\n\n` +
272
+ `Complete your checkout at: https://cachly.dev/instances`;
273
+ }
274
+ else {
275
+ hint = `Brain instance "${inst.name}" is not reachable (status: ${inst.status}).\n\n` +
243
276
  `• View your instance at: https://cachly.dev/instances\n` +
244
277
  `• Run \`get_api_status\` for a full diagnostic.`;
278
+ }
245
279
  throw new McpError(ErrorCode.InvalidRequest, hint);
246
280
  }
247
281
  if (!inst.host || !inst.port) {
@@ -255,8 +289,14 @@ async function getConnection(instance_id) {
255
289
  password = conn.password ?? undefined;
256
290
  tlsEnabled = conn.tls_enabled !== false;
257
291
  }
258
- catch {
259
- // Fallback: use TLS flag from instance metadata
292
+ catch (connErr) {
293
+ // If the connection endpoint fails and the instance has a password, we cannot connect.
294
+ // Surface the error so the user knows why Redis auth will fail rather than getting a cryptic NOAUTH.
295
+ const msg = connErr instanceof Error ? connErr.message : String(connErr);
296
+ if (msg.includes('401') || msg.includes('403') || msg.includes('Unauthorized')) {
297
+ throw new McpError(ErrorCode.InvalidRequest, `API key rejected — run \`cachly setup\` to refresh your credentials.`);
298
+ }
299
+ // Other failures (timeout, 500): proceed without password, Redis will surface NOAUTH if needed
260
300
  }
261
301
  const client = new Redis({
262
302
  host: inst.host,
@@ -327,23 +367,40 @@ import { TOOLS } from './tools.js';
327
367
  // ── Handlers ──────────────────────────────────────────────────────────────────
328
368
  // Fires once per process when no JWT is set (anonymous, opt-out via CACHLY_NO_TELEMETRY=1)
329
369
  let _telemetryPingSent = false;
370
+ // Fires once per process on the first successful Brain tool call — key activation metric
371
+ let _firstCallSuccessSent = false;
372
+ function detectEditor() {
373
+ return process.env.CURSOR_TRACE_ID ? 'cursor'
374
+ : process.env.WINDSURF_SESSION_ID ? 'windsurf'
375
+ : process.env.GITHUB_COPILOT_WORKSPACE ? 'copilot'
376
+ : process.env.CLAUDE_CODE_ENTRYPOINT ? 'claude'
377
+ : 'unknown';
378
+ }
379
+ function sendFunnelEvent(event, extra) {
380
+ if (process.env.CACHLY_NO_TELEMETRY === '1')
381
+ return;
382
+ void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
383
+ method: 'POST',
384
+ headers: { 'Content-Type': 'application/json' },
385
+ body: JSON.stringify({
386
+ event, version: CURRENT_VERSION, editor: detectEditor(),
387
+ ...(JWT ? { jwt: JWT } : {}),
388
+ ...extra,
389
+ }),
390
+ signal: AbortSignal.timeout(3000),
391
+ }).catch(() => { });
392
+ }
330
393
  async function sendAnonymousTelemetry(toolName) {
331
394
  if (_telemetryPingSent)
332
395
  return;
333
396
  if (process.env.CACHLY_NO_TELEMETRY === '1')
334
397
  return;
335
398
  _telemetryPingSent = true;
336
- // Detect editor from common env vars injected by IDE extensions
337
- const editor = process.env.CURSOR_TRACE_ID ? 'cursor'
338
- : process.env.WINDSURF_SESSION_ID ? 'windsurf'
339
- : process.env.GITHUB_COPILOT_WORKSPACE ? 'copilot'
340
- : process.env.CLAUDE_CODE_ENTRYPOINT ? 'claude'
341
- : 'unknown';
342
399
  try {
343
400
  await fetch(`${API_URL}/api/v1/telemetry/mcp`, {
344
401
  method: 'POST',
345
402
  headers: { 'Content-Type': 'application/json' },
346
- body: JSON.stringify({ event: 'first_call_no_jwt', version: CURRENT_VERSION, editor, tool: toolName }),
403
+ body: JSON.stringify({ event: 'first_call_no_jwt', version: CURRENT_VERSION, editor: detectEditor(), tool: toolName }),
347
404
  signal: AbortSignal.timeout(3000),
348
405
  });
349
406
  }
@@ -380,18 +437,29 @@ async function handleTool(name, args) {
380
437
  const flow = await startDeviceFlow();
381
438
  if (flow) {
382
439
  _deviceFlow = flow;
440
+ sendFunnelEvent('device_flow_started', { tool: name });
441
+ // Try to open the browser automatically — fire-and-forget, never block
442
+ void (async () => {
443
+ try {
444
+ const { exec } = await import('node:child_process');
445
+ const url = flow.verifyUrl;
446
+ const cmd = process.platform === 'win32' ? `start "" "${url}"`
447
+ : process.platform === 'darwin' ? `open "${url}"`
448
+ : `xdg-open "${url}"`;
449
+ exec(cmd);
450
+ }
451
+ catch { /* non-critical */ }
452
+ })();
383
453
  return [
384
- '🧠 **cachly AI Brain — One-click sign in**',
454
+ '🧠 **cachly AI Brain — sign in to activate** (browser opening...)',
385
455
  '',
386
- '1. Open this URL in your browser (it may open automatically):',
387
- ` **${flow.verifyUrl}**`,
456
+ `👉 **${flow.verifyUrl}**`,
388
457
  '',
389
- `2. Enter this code if prompted: **${flow.userCode}**`,
458
+ `Code: **${flow.userCode}** (pre-filled if browser opened automatically)`,
390
459
  '',
391
- '3. After sign-in, call this tool again — it will proceed automatically.',
460
+ 'After sign-in: call **any tool again** — your Brain activates instantly.',
392
461
  '',
393
- '✨ Free tier includes: 1 Brain instance, persistent memory, 63 MCP tools.',
394
- ' No credit card required.',
462
+ '✨ Free forever · No credit card · 89 MCP tools · GDPR · EU servers',
395
463
  ].join('\n');
396
464
  }
397
465
  // Device flow unavailable (network issue) — fall back to manual setup
@@ -416,12 +484,85 @@ async function handleTool(name, args) {
416
484
  }
417
485
  // Delegate brain tools (learn, recall, session, etc.)
418
486
  const brainResult = await handleBrainTool(name, args, getConnection, apiFetch);
419
- if (brainResult !== null)
487
+ if (brainResult !== null) {
488
+ if (!_firstCallSuccessSent && JWT) {
489
+ _firstCallSuccessSent = true;
490
+ sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? _defaultInstanceId ?? '' });
491
+ }
492
+ // Per-tool telemetry — fires after every successful brain tool call.
493
+ // api_key carries the cky_live_... token so the backend can resolve tenant + increment counters.
494
+ const instanceId = args.instance_id ?? _defaultInstanceId ?? '';
495
+ const telemetryExtra = JWT ? { api_key: JWT, instance_id: instanceId } : { instance_id: instanceId };
496
+ if (name === 'recall_best_solution') {
497
+ // recall_best_solution → increments BrainRecallCount + triggers level-up logic
498
+ sendFunnelEvent('recall_best_solution', telemetryExtra);
499
+ }
500
+ else if (name === 'learn_from_attempts') {
501
+ sendFunnelEvent('learn_from_attempts', telemetryExtra);
502
+ }
503
+ else if (name === 'session_start') {
504
+ sendFunnelEvent('session_start', telemetryExtra);
505
+ const resultText = typeof brainResult === 'object' && brainResult !== null && 'content' in brainResult
506
+ ? JSON.stringify(brainResult)
507
+ : String(brainResult ?? '');
508
+ const isFirstSession = resultText.includes('Welcome! Your AI Brain is live.');
509
+ if (!isFirstSession && resultText.length > 100) {
510
+ // Existing brain — session_start acts as implicit recall → increment counter.
511
+ sendFunnelEvent('recall_best_solution', telemetryExtra);
512
+ }
513
+ else if (isFirstSession && args.workspace_path) {
514
+ // First session with a known workspace: auto-bootstrap from git history.
515
+ // Runs synchronously so the user sees the result in the same response.
516
+ try {
517
+ const gitBootstrap = await handleFedbrainTool('brain_from_git', {
518
+ instance_id: instanceId,
519
+ repo_path: args.workspace_path,
520
+ limit: 50,
521
+ }, getConnection, apiFetch);
522
+ if (gitBootstrap !== null) {
523
+ sendFunnelEvent('brain_from_git', telemetryExtra);
524
+ sendFunnelEvent('recall_best_solution', telemetryExtra);
525
+ // Append bootstrap summary to the session_start briefing.
526
+ const bootstrapText = typeof gitBootstrap === 'object' && gitBootstrap !== null && 'content' in gitBootstrap
527
+ ? gitBootstrap.content.map((c) => c.text ?? '').join('\n')
528
+ : String(gitBootstrap);
529
+ if (typeof brainResult === 'object' && brainResult !== null && 'content' in brainResult) {
530
+ brainResult.content.push({
531
+ type: 'text',
532
+ text: '\n---\n' + bootstrapText,
533
+ });
534
+ }
535
+ }
536
+ }
537
+ catch { /* git bootstrap errors must never break session_start */ }
538
+ }
539
+ }
540
+ else if (name === 'session_end') {
541
+ sendFunnelEvent('session_end', telemetryExtra);
542
+ }
543
+ else if (name === 'smart_recall') {
544
+ sendFunnelEvent('smart_recall', telemetryExtra);
545
+ // smart_recall is the primary recall tool in CLAUDE.md — it retrieves brain lessons
546
+ // just like recall_best_solution. Count it toward BrainRecallCount so the dashboard
547
+ // nudge clears and the first-recall email fires for users following CLAUDE.md.
548
+ const srText = typeof brainResult === 'object' && brainResult !== null && 'content' in brainResult
549
+ ? JSON.stringify(brainResult)
550
+ : String(brainResult ?? '');
551
+ if (srText.length > 50 && !srText.includes('No lessons found') && !srText.includes('no lessons')) {
552
+ sendFunnelEvent('recall_best_solution', telemetryExtra);
553
+ }
554
+ }
420
555
  return brainResult;
556
+ }
421
557
  // Delegate context tools (remember/recall/list/forget)
422
558
  const contextResult = await handleContextTool(name, args, getConnection, apiFetch);
423
- if (contextResult !== null)
559
+ if (contextResult !== null) {
560
+ if (!_firstCallSuccessSent && JWT) {
561
+ _firstCallSuccessSent = true;
562
+ sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? _defaultInstanceId ?? '' });
563
+ }
424
564
  return contextResult;
565
+ }
425
566
  // Delegate instance + cache tools
426
567
  const instanceResult = await handleInstanceTool(name, args, getConnection, apiFetch);
427
568
  if (instanceResult !== null)
@@ -440,11 +581,28 @@ async function handleTool(name, args) {
440
581
  if (advancedResult !== null)
441
582
  return advancedResult;
442
583
  const syndicateResult = await handleSyndicateTool(name, args, getConnection, apiFetch);
443
- if (syndicateResult !== null)
584
+ if (syndicateResult !== null) {
585
+ // brain_predict is in syndicate handler — track telemetry here.
586
+ if (name === 'brain_predict') {
587
+ const instanceId = args.instance_id ?? _defaultInstanceId ?? '';
588
+ const telemetryExtra = JWT ? { api_key: JWT, instance_id: instanceId } : { instance_id: instanceId };
589
+ sendFunnelEvent('brain_predict', telemetryExtra);
590
+ }
444
591
  return syndicateResult;
592
+ }
445
593
  const fedbrainResult = await handleFedbrainTool(name, args, getConnection, apiFetch);
446
- if (fedbrainResult !== null)
594
+ if (fedbrainResult !== null) {
595
+ // brain_from_git and brain_predict_failures are in fedbrain handler.
596
+ const instanceId = args.instance_id ?? _defaultInstanceId ?? '';
597
+ const telemetryExtra = JWT ? { api_key: JWT, instance_id: instanceId } : { instance_id: instanceId };
598
+ if (name === 'brain_from_git') {
599
+ sendFunnelEvent('brain_from_git', telemetryExtra);
600
+ }
601
+ else if (name === 'brain_predict_failures') {
602
+ sendFunnelEvent('brain_predict_failures', telemetryExtra);
603
+ }
447
604
  return fedbrainResult;
605
+ }
448
606
  switch (name) {
449
607
  // ── Instance management ──────────────────────────────────────────────
450
608
  case 'get_api_status': {
@@ -557,32 +715,7 @@ async function handleTool(name, args) {
557
715
  }
558
716
  }
559
717
  const server = new Server({ name: 'cachly-mcp', version: CURRENT_VERSION }, { capabilities: { tools: {} } });
560
- server.setRequestHandler(ListToolsRequestSchema, async () => {
561
- if (!JWT) {
562
- // No credentials — inject sign-up CTA into session_start description
563
- const tools = TOOLS.map(t => {
564
- if (t.name !== 'session_start')
565
- return t;
566
- return {
567
- ...t,
568
- description: `⚡ FREE AI BRAIN — sign up once at https://cachly.dev/sign-up (no credit card) ` +
569
- `then run: npx @cachly-dev/mcp-server@latest setup\n\n` + t.description,
570
- };
571
- });
572
- return { tools };
573
- }
574
- // ── Auto-start session on connection (fire and forget) ────────────────────
575
- // When the AI reads the tools list, it has just connected. Proactively start
576
- // the Brain session in the background so by the time the first tool call
577
- // arrives, session context is already loaded and ready to inject.
578
- if (!_autoSessionStarted && !_autoSessionStarting) {
579
- void resolveDefaultInstanceId().then(instanceId => {
580
- if (instanceId)
581
- void autoStartSession(instanceId).catch(() => undefined);
582
- }).catch(() => undefined);
583
- }
584
- return { tools: TOOLS };
585
- });
718
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
586
719
  // ── Auto-session management ───────────────────────────────────────────────────
587
720
  // Transparently starts a Brain session on the first tool call of a process
588
721
  // and saves a session_end summary on SIGTERM/exit so users never have to call
@@ -591,87 +724,62 @@ let _autoSessionInstanceId = null;
591
724
  let _autoSessionStarted = false;
592
725
  let _autoSessionStarting = null; // guard against double-start race
593
726
  let _autoSessionToolCount = 0;
594
- let _autoSessionContext = null; // result of session_start, injected into first tool response
595
- let _autoSessionContextInjected = false; // only inject once
596
727
  // ── brain_from_git concurrency limiter ────────────────────────────────────────
597
728
  // Spawning many `git log` subprocesses in parallel can fork-bomb the host.
598
729
  // This simple semaphore caps concurrent brain_from_git calls to 10 per process.
599
730
  async function autoStartSession(instanceId) {
600
731
  if (_autoSessionStarted)
601
- return null;
732
+ return;
602
733
  // If another call already started the session, await it instead of double-starting.
603
734
  if (_autoSessionStarting) {
604
- return _autoSessionStarting;
735
+ await _autoSessionStarting;
736
+ return;
605
737
  }
606
738
  _autoSessionStarting = (async () => {
607
739
  _autoSessionStarted = true;
608
740
  _autoSessionInstanceId = instanceId;
609
- let sessionResult = null;
610
741
  try {
611
- // Auto-detect git context for a meaningful session focus
612
- let focus = 'auto (MCP session)';
613
- try {
614
- const { execSync } = await import('node:child_process');
615
- const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }).trim();
616
- const lastMsg = execSync('git log -1 --pretty=%s', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }).trim();
617
- if (branch && branch !== 'HEAD') {
618
- focus = lastMsg ? `${branch}: ${lastMsg.slice(0, 80)}` : `branch: ${branch}`;
619
- }
620
- }
621
- catch { /* git not available in this workspace */ }
622
- sessionResult = await handleTool('session_start', { instance_id: instanceId, focus });
623
- _autoSessionContext = sessionResult;
742
+ await handleTool('session_start', { instance_id: instanceId, focus: 'auto (MCP session)', workspace_path: process.cwd() });
624
743
  }
625
744
  catch { /* non-fatal — session tracking is a best-effort feature */ }
626
745
  finally {
627
746
  _autoSessionStarting = null;
628
747
  }
629
- // Auto-index the project if it hasn't been indexed in the last 24h.
630
- // This is the main lever for growing token savings: more indexed code →
631
- // more semantic cache hits → fewer LLM calls for repeated questions.
632
- if (process.env.CACHLY_AUTO_INDEX !== 'false') {
633
- try {
634
- const redis = await getConnection(instanceId);
635
- const lastIndexed = await redis.get(`cachly:index:last_indexed:${instanceId}`);
636
- const staleMs = 24 * 60 * 60 * 1000;
637
- const isStale = !lastIndexed || (Date.now() - parseInt(lastIndexed, 10)) > staleMs;
638
- if (isStale) {
639
- // Mark as indexing now to prevent concurrent re-runs.
640
- await redis.set(`cachly:index:last_indexed:${instanceId}`, String(Date.now()), 'EX', 90000);
641
- // Run in background — don't block the first tool call.
642
- handleTool('index_project', {
643
- instance_id: instanceId,
644
- dir: process.cwd(),
645
- max_files: 150,
646
- ttl: 86400 * 7, // cache for 7 days
647
- namespace: 'cachly:sem:code',
648
- }).catch(() => undefined);
649
- }
748
+ })();
749
+ await _autoSessionStarting;
750
+ // Auto-index the project if it hasn't been indexed in the last 24h.
751
+ // This is the main lever for growing token savings: more indexed code →
752
+ // more semantic cache hits → fewer LLM calls for repeated questions.
753
+ if (process.env.CACHLY_AUTO_INDEX !== 'false') {
754
+ try {
755
+ const redis = await getConnection(instanceId);
756
+ const lastIndexed = await redis.get(`cachly:index:last_indexed:${instanceId}`);
757
+ const staleMs = 24 * 60 * 60 * 1000;
758
+ const isStale = !lastIndexed || (Date.now() - parseInt(lastIndexed, 10)) > staleMs;
759
+ if (isStale) {
760
+ // Mark as indexing now to prevent concurrent re-runs.
761
+ await redis.set(`cachly:index:last_indexed:${instanceId}`, String(Date.now()), 'EX', 90000);
762
+ // Run in background — don't block the first tool call.
763
+ handleTool('index_project', {
764
+ instance_id: instanceId,
765
+ dir: process.cwd(),
766
+ max_files: 150,
767
+ ttl: 86400 * 7, // cache for 7 days
768
+ namespace: 'cachly:sem:code',
769
+ }).catch(() => undefined);
650
770
  }
651
- catch { /* never block the session on indexing errors */ }
652
771
  }
653
- return sessionResult;
654
- })();
655
- return _autoSessionStarting;
772
+ catch { /* never block the session on indexing errors */ }
773
+ }
656
774
  }
657
775
  async function autoEndSession() {
658
776
  if (!_autoSessionStarted || !_autoSessionInstanceId)
659
777
  return;
660
778
  _autoSessionStarted = false;
661
779
  try {
662
- // Try to derive a meaningful summary from git
663
- let summary = `Auto-ended after ${_autoSessionToolCount} tool call(s).`;
664
- try {
665
- const { execSync } = await import('node:child_process');
666
- const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }).trim();
667
- const lastMsg = execSync('git log -1 --pretty=%s', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }).trim();
668
- if (lastMsg)
669
- summary = `${branch ? `[${branch}] ` : ''}${lastMsg} · ${_autoSessionToolCount} tool calls`;
670
- }
671
- catch { /* git unavailable */ }
672
780
  await handleTool('session_end', {
673
781
  instance_id: _autoSessionInstanceId,
674
- summary,
782
+ summary: `Auto-ended after ${_autoSessionToolCount} tool call(s). Session was started automatically.`,
675
783
  files_changed: [],
676
784
  lessons_learned: 0,
677
785
  });
@@ -680,7 +788,7 @@ async function autoEndSession() {
680
788
  }
681
789
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
682
790
  const { name, arguments: args } = request.params;
683
- // Auto-start brain session if not yet started (fallback: ListTools already fired this).
791
+ // Auto-start brain session on first tool call that has an instance_id.
684
792
  // Skip session management tools to avoid recursion.
685
793
  const sessionTools = new Set(['session_start', 'session_end', 'auto_learn_session']);
686
794
  if (!sessionTools.has(name) && !_autoSessionStarted && !_autoSessionStarting) {
@@ -699,16 +807,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
699
807
  _autoSessionToolCount++;
700
808
  try {
701
809
  const text = await handleTool(name, (args ?? {}));
702
- // ── Inject session context into the FIRST tool response ─────────────────
703
- // This is the key: the AI sees the previous session summary, handoff tasks,
704
- // and recent lessons automatically — without needing to call session_start.
705
- // Only injected once per process (on the very first tool call).
706
- if (!_autoSessionContextInjected && _autoSessionContext && !sessionTools.has(name)) {
707
- _autoSessionContextInjected = true;
708
- const ctx = _autoSessionContext;
709
- _autoSessionContext = null; // free memory
710
- return { content: [{ type: 'text', text: `${ctx}\n\n---\n\n${text}` }] };
711
- }
712
810
  return { content: [{ type: 'text', text }] };
713
811
  }
714
812
  catch (err) {
@@ -784,111 +882,53 @@ function buildMcpConfig(apiKey, instanceId, editor) {
784
882
  },
785
883
  }, null, 2);
786
884
  }
787
- /**
788
- * Build only the per-server entry that gets merged into an existing config.
789
- * Returned shape varies by editor — see writeMergedMcpConfig for the merge
790
- * locations.
791
- */
792
- function buildCachlyServerEntry(apiKey, instanceId, editor) {
793
- if (editor === 'continue') {
794
- return {
795
- transport: { type: 'stdio', command: 'npx', args: ['-y', '@cachly-dev/mcp-server@latest'] },
796
- env: { CACHLY_API_URL: 'https://api.cachly.dev', CACHLY_JWT: apiKey, CACHLY_BRAIN_INSTANCE_ID: instanceId },
797
- };
798
- }
799
- if (editor === 'zed') {
800
- return {
801
- command: {
802
- path: 'npx',
803
- args: ['-y', '@cachly-dev/mcp-server@latest'],
804
- env: { CACHLY_API_URL: 'https://api.cachly.dev', CACHLY_JWT: apiKey, CACHLY_BRAIN_INSTANCE_ID: instanceId },
805
- },
806
- settings: {},
807
- };
808
- }
809
- return {
885
+ // mergeMcpConfig reads an existing config file (if any), merges the cachly entry,
886
+ // and returns the updated JSON string — preserving all other MCP servers and settings.
887
+ async function mergeMcpConfig(configPath, apiKey, instanceId, editor, fsOps) {
888
+ const cachlyEntry = {
810
889
  command: 'npx',
811
890
  args: ['-y', '@cachly-dev/mcp-server@latest'],
812
891
  env: { CACHLY_API_URL: 'https://api.cachly.dev', CACHLY_JWT: apiKey, CACHLY_BRAIN_INSTANCE_ID: instanceId },
813
892
  };
814
- }
815
- /**
816
- * Merge the cachly server into an existing editor MCP config file — preserving
817
- * every other MCP server the user has configured. Creates the file if missing.
818
- * Writes a one-time .cachly-backup-<timestamp> next to the file before any
819
- * mutation so the user can recover from corrupt/unexpected merges.
820
- *
821
- * Returns 'created' if the file did not exist, 'updated' if cachly was already
822
- * present (re-pointed to a possibly new instance), or 'merged' if cachly was
823
- * added alongside other servers.
824
- */
825
- async function writeMergedMcpConfig(configPath, apiKey, instanceId, editor) {
826
- const { writeFile, readFile, mkdir, copyFile } = await import('node:fs/promises');
827
- const { existsSync } = await import('node:fs');
828
- const { dirname } = await import('node:path');
829
- await mkdir(dirname(configPath), { recursive: true });
830
- // No existing file → write fresh full config.
831
- if (!existsSync(configPath)) {
832
- await writeFile(configPath, buildMcpConfig(apiKey, instanceId, editor), 'utf-8');
833
- return 'created';
834
- }
835
- // Parse existing config. On parse failure, back it up and start fresh
836
- // (never silently destroy user data — the .cachly-backup gives them a
837
- // recovery path).
838
- const raw = await readFile(configPath, 'utf-8');
839
- let existing;
893
+ if (!fsOps.existsSync(configPath))
894
+ return buildMcpConfig(apiKey, instanceId, editor);
895
+ let existing = {};
840
896
  try {
841
- existing = JSON.parse(raw);
842
- if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
843
- throw new Error('config root is not an object');
844
- }
897
+ existing = JSON.parse(await fsOps.readFile(configPath, 'utf-8'));
845
898
  }
846
899
  catch {
847
- const backupPath = `${configPath}.cachly-backup-${Date.now()}`;
848
- await copyFile(configPath, backupPath);
849
- await writeFile(configPath, buildMcpConfig(apiKey, instanceId, editor), 'utf-8');
850
- return 'created';
851
- }
852
- // Defensive backup of the valid config before mutation — first time only.
853
- // The cachly-backup file is overwritten on each re-init so we don't pollute
854
- // the project, but it gives the user one undo level.
855
- const backupPath = `${configPath}.cachly-backup`;
856
- try {
857
- await copyFile(configPath, backupPath);
900
+ // Corrupt or unreadable JSON — back it up by appending .bak, then start fresh
901
+ try {
902
+ const { rename } = await import('node:fs/promises');
903
+ await rename(configPath, configPath + '.bak');
904
+ process.stderr.write(`⚠️ ${configPath} was not valid JSON — backed up to ${configPath}.bak\n`);
905
+ }
906
+ catch { /* backup failed — still safe to overwrite with valid config */ }
858
907
  }
859
- catch { /* non-critical — backup is best-effort */ }
860
- const entry = buildCachlyServerEntry(apiKey, instanceId, editor);
861
- let hadCachly = false;
862
908
  if (editor === 'continue') {
863
- const exp = existing.experimental ?? {};
864
- const servers = Array.isArray(exp.modelContextProtocolServers)
865
- ? exp.modelContextProtocolServers
866
- : [];
867
- const isCachly = (s) => s?.transport?.args?.some((a) => typeof a === 'string' && a.includes('@cachly-dev/mcp-server')) ?? false;
909
+ // Merge into experimental.modelContextProtocolServers array — replace cachly entry if present
910
+ const exp = (existing['experimental'] ?? {});
911
+ const servers = (exp['modelContextProtocolServers'] ?? []);
868
912
  const filtered = servers.filter((s) => {
869
- if (isCachly(s)) {
870
- hadCachly = true;
871
- return false;
872
- }
873
- return true;
913
+ const env = (s['env'] ?? {});
914
+ return !env['CACHLY_JWT'] && !env['CACHLY_BRAIN_INSTANCE_ID'];
874
915
  });
875
- filtered.push(entry);
876
- existing.experimental = { ...exp, modelContextProtocolServers: filtered };
877
- }
878
- else if (editor === 'zed') {
879
- // context_servers is a Record — set cachly key.
880
- const cs = existing.context_servers ?? {};
881
- hadCachly = 'cachly' in cs;
882
- existing.context_servers = { ...cs, cachly: entry };
916
+ filtered.push({ transport: { type: 'stdio', command: 'npx', args: ['-y', '@cachly-dev/mcp-server@latest'] }, env: cachlyEntry.env });
917
+ existing['experimental'] = { ...exp, modelContextProtocolServers: filtered };
918
+ return JSON.stringify(existing, null, 2);
883
919
  }
884
- else {
885
- // Standard mcpServers map — Claude / Cursor / Windsurf / VS Code / Cline.
886
- const mcp = existing.mcpServers ?? {};
887
- hadCachly = 'cachly' in mcp;
888
- existing.mcpServers = { ...mcp, cachly: entry };
920
+ if (editor === 'zed') {
921
+ // Merge only context_servers.cachly — preserve all other Zed settings
922
+ const cs = (existing['context_servers'] ?? {});
923
+ cs['cachly'] = { command: { path: 'npx', args: ['-y', '@cachly-dev/mcp-server@latest'], env: cachlyEntry.env }, settings: {} };
924
+ existing['context_servers'] = cs;
925
+ return JSON.stringify(existing, null, 2);
889
926
  }
890
- await writeFile(configPath, JSON.stringify(existing, null, 2), 'utf-8');
891
- return hadCachly ? 'updated' : 'merged';
927
+ // Standard mcpServers format (Claude Code, Cursor, Windsurf, Copilot, Cline)
928
+ const servers = (existing['mcpServers'] ?? {});
929
+ servers['cachly'] = cachlyEntry;
930
+ existing['mcpServers'] = servers;
931
+ return JSON.stringify(existing, null, 2);
892
932
  }
893
933
  function buildClaudeMdBlock(instanceId) {
894
934
  return `${CLAUDE_MD_MARKER_START}
@@ -896,15 +936,23 @@ function buildClaudeMdBlock(instanceId) {
896
936
 
897
937
  The Brain MCP server runs automatically. Instance: \`${instanceId}\`
898
938
 
899
- Sessions start and end automatically — never call \`session_start\` or \`session_end\`.
900
-
901
939
  ---
902
940
 
941
+ ### AT THE START of every new conversation (mandatory — first thing, before anything else):
942
+ \`\`\`
943
+ session_start(instance_id="${instanceId}", workspace_path="<absolute path to this project>", focus="<first topic the user mentioned>")
944
+ \`\`\`
945
+ Show the full briefing output to the user. If the response says the brain is empty or has 0 lessons, immediately seed it:
946
+ \`\`\`
947
+ brain_from_git(instance_id="${instanceId}", repo_path="<workspace_path>", limit=100)
948
+ \`\`\`
949
+ Then tell the user: "Your brain just learned from your git history. You're now pre-briefed."
950
+
903
951
  ### BEFORE every task (mandatory — no exceptions):
904
952
  \`\`\`
905
953
  smart_recall(instance_id="${instanceId}", query="<what you are about to do>")
906
954
  \`\`\`
907
- Report any relevant lessons found to the user before proceeding.
955
+ If relevant lessons are found, say: "Your brain knows this pattern — [lesson]." Then proceed.
908
956
 
909
957
  ### BEFORE editing any file (mandatory):
910
958
  \`\`\`
@@ -919,6 +967,11 @@ learn_from_attempts(instance_id="${instanceId}", topic="<category:keyword>", out
919
967
  file_paths=["..."], commands=["..."], tags=["..."])
920
968
  \`\`\`
921
969
 
970
+ ### AT THE END of every conversation (mandatory):
971
+ \`\`\`
972
+ session_end(instance_id="${instanceId}", summary="<one sentence: what was done>", files_changed=["..."])
973
+ \`\`\`
974
+
922
975
  ### WHEN debugging any error (mandatory before grepping/reading files):
923
976
  \`\`\`
924
977
  causal_trace(instance_id="${instanceId}", problem="<exact error or symptom>")
@@ -955,6 +1008,646 @@ async function writeClaudeMd(projectDir, instanceId) {
955
1008
  await writeFile(claudeMdPath, block.trimStart(), 'utf-8');
956
1009
  return 'written';
957
1010
  }
1011
+ // ── CLI: cachly digest ────────────────────────────────────────────────────────
1012
+ // Usage: npx @cachly-dev/mcp-server@latest digest
1013
+ // Weekly brain summary — what your AI learned this week. Shareable output.
1014
+ if (process.argv[2] === 'digest') {
1015
+ const apiKey = process.env.CACHLY_JWT ?? '';
1016
+ const instanceId = process.env.CACHLY_BRAIN_INSTANCE_ID ?? '';
1017
+ if (!apiKey || !instanceId) {
1018
+ console.log('\n⚠️ CACHLY_JWT and CACHLY_BRAIN_INSTANCE_ID must be set.');
1019
+ console.log(' Run: npx @cachly-dev/mcp-server@latest setup\n');
1020
+ process.exit(1);
1021
+ }
1022
+ process.stdout.write('\n🧠 Fetching your weekly Brain digest...\n\n');
1023
+ try {
1024
+ // Fetch current stats
1025
+ const statsRes = await fetch(`${API_URL}/api/v1/instances/${instanceId}/brain/stats`, {
1026
+ headers: { Authorization: `Bearer ${apiKey}` },
1027
+ signal: AbortSignal.timeout(8000),
1028
+ });
1029
+ if (!statsRes.ok)
1030
+ throw new Error(`stats HTTP ${statsRes.status}`);
1031
+ const stats = await statsRes.json();
1032
+ const lessons = stats.lesson_count ?? 0;
1033
+ const recalls = stats.total_recall_count ?? 0;
1034
+ const score = Math.round((stats.quality_score ?? 0) * 100);
1035
+ const topLessons = (stats.top_lessons ?? []).slice(0, 5);
1036
+ const teamAuthors = stats.team_authors ?? [];
1037
+ const tokensSaved = recalls * 1200;
1038
+ const costSaved = (tokensSaved * 0.000003).toFixed(2);
1039
+ const level = lessons < 10 ? 'Junior Dev 🔧' : lessons < 30 ? 'Mid Dev ⚡' :
1040
+ lessons < 60 ? 'Senior Dev 🧠' : lessons < 100 ? 'Staff Eng 🚀' : 'Principal Eng 🏆';
1041
+ const now = new Date();
1042
+ const weekStart = new Date(now);
1043
+ weekStart.setDate(now.getDate() - 7);
1044
+ const fmt = (d) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
1045
+ console.log('┌─────────────────────────────────────────────────────────────┐');
1046
+ console.log(`│ \x1b[1m🧠 Brain Weekly Digest\x1b[0m \x1b[90m${fmt(weekStart)} – ${fmt(now)}\x1b[0m │`);
1047
+ console.log('├─────────────────────────────────────────────────────────────┤');
1048
+ console.log(`│ Total lessons : \x1b[33m${String(lessons).padEnd(8)}\x1b[0m Brain level : \x1b[32m${level.padEnd(18)}\x1b[0m│`);
1049
+ console.log(`│ Total recalls : \x1b[36m${String(recalls).padEnd(8)}\x1b[0m Quality score: \x1b[32m${String(score).padEnd(3)}%\x1b[0m │`);
1050
+ console.log(`│ Tokens saved : \x1b[32m${String(Math.round(tokensSaved / 1000) + 'K').padEnd(8)}\x1b[0m Cost saved : \x1b[32m$${costSaved.padEnd(20)}\x1b[0m│`);
1051
+ if (teamAuthors.length > 0) {
1052
+ console.log('├─────────────────────────────────────────────────────────────┤');
1053
+ console.log(`│ \x1b[36m👥 Team contributors:\x1b[0m ${teamAuthors.slice(0, 5).join(', ').slice(0, 42).padEnd(42)} │`);
1054
+ }
1055
+ if (topLessons.length > 0) {
1056
+ console.log('├─────────────────────────────────────────────────────────────┤');
1057
+ console.log('│ \x1b[33m🔥 Most recalled lessons this week:\x1b[0m │');
1058
+ for (const l of topLessons) {
1059
+ const line = `${l.topic}: ${l.what_worked}`.slice(0, 57);
1060
+ console.log(`│ \x1b[90m• ${line.padEnd(58)}\x1b[0m│`);
1061
+ }
1062
+ }
1063
+ console.log('└─────────────────────────────────────────────────────────────┘');
1064
+ console.log('');
1065
+ // ── Shareable tweet card ────────────────────────────────────────────────
1066
+ const topLesson = topLessons[0];
1067
+ const tweetLines = [
1068
+ `🧠 My AI Brain weekly digest (${fmt(weekStart)} – ${fmt(now)}):`,
1069
+ ``,
1070
+ ` 📚 ${lessons} lessons learned`,
1071
+ ` 🔁 ${recalls} recalls · ~${Math.round(tokensSaved / 1000)}K tokens saved`,
1072
+ ` 🎯 Brain Level: ${level}`,
1073
+ topLesson ? ` 🔥 Top lesson: "${topLesson.topic}: ${topLesson.what_worked.slice(0, 60)}${topLesson.what_worked.length > 60 ? '…' : ''}"` : '',
1074
+ ``,
1075
+ `Built with @cachly_dev — AI that actually remembers 🚀`,
1076
+ `cachly.dev`,
1077
+ ].filter(Boolean).join('\n');
1078
+ const tweetUrl = 'https://twitter.com/intent/tweet?text=' + encodeURIComponent(tweetLines);
1079
+ console.log('\x1b[1m📣 Share your digest:\x1b[0m');
1080
+ console.log('');
1081
+ console.log('\x1b[90m' + '─'.repeat(63) + '\x1b[0m');
1082
+ for (const line of tweetLines.split('\n')) {
1083
+ console.log(` ${line}`);
1084
+ }
1085
+ console.log('\x1b[90m' + '─'.repeat(63) + '\x1b[0m');
1086
+ console.log('');
1087
+ console.log(' \x1b[36m🐦 Tweet this:\x1b[0m');
1088
+ console.log(` \x1b[4m${tweetUrl.slice(0, 90)}...\x1b[0m`);
1089
+ console.log('');
1090
+ console.log(' \x1b[2m💡 Invite your team: npx @cachly-dev/mcp-server@latest invite\x1b[0m');
1091
+ console.log(' \x1b[2m Cron: 0 9 * * 1 npx @cachly-dev/mcp-server@latest digest\x1b[0m');
1092
+ console.log('');
1093
+ }
1094
+ catch (e) {
1095
+ console.log(`\n❌ Could not fetch digest: ${e.message}\n`);
1096
+ process.exit(1);
1097
+ }
1098
+ process.exit(0);
1099
+ }
1100
+ // ── CLI: cachly invite ────────────────────────────────────────────────────────
1101
+ // Usage: npx @cachly-dev/mcp-server@latest invite [email]
1102
+ // Invites a teammate to share your Brain. Fastest referral loop.
1103
+ if (process.argv[2] === 'invite') {
1104
+ // ── invite: fetch the user's unique referral link and show shareable messages ──
1105
+ const apiKey = process.env.CACHLY_JWT ?? '';
1106
+ if (!apiKey) {
1107
+ console.log('\n⚠️ CACHLY_JWT must be set.');
1108
+ console.log(' Run: npx @cachly-dev/mcp-server@latest setup\n');
1109
+ process.exit(1);
1110
+ }
1111
+ try {
1112
+ const res = await fetch(`${API_URL}/api/v1/referral/me`, {
1113
+ headers: { Authorization: `Bearer ${apiKey}` },
1114
+ signal: AbortSignal.timeout(8000),
1115
+ });
1116
+ if (!res.ok)
1117
+ throw new Error(`HTTP ${res.status}`);
1118
+ const data = await res.json();
1119
+ const referralUrl = data.url ?? `https://cachly.dev/r/${data.code}`;
1120
+ const count = data.referral_count ?? 0;
1121
+ const slackMsg = `Hey, I've been using cachly to give my AI persistent memory across sessions — no more re-explaining my stack every morning.\n\nYou get a free Brain: ${referralUrl}`;
1122
+ const tweetText = encodeURIComponent(`I gave my AI persistent memory. It remembers fixes, patterns, and context across every session — no more re-explaining.\n\nTry it free: ${referralUrl}\n\n@cachlydev`);
1123
+ console.log('');
1124
+ console.log('┌─────────────────────────────────────────────────────────────┐');
1125
+ console.log('│ 🧠 Your cachly Brain invite link │');
1126
+ console.log('├─────────────────────────────────────────────────────────────┤');
1127
+ console.log(`│ ${referralUrl.padEnd(61)}│`);
1128
+ console.log('├─────────────────────────────────────────────────────────────┤');
1129
+ if (count > 0) {
1130
+ console.log(`│ \x1b[32m✓ ${count} developer${count === 1 ? '' : 's'} joined via your link so far\x1b[0m`.padEnd(72) + '│');
1131
+ console.log('├─────────────────────────────────────────────────────────────┤');
1132
+ }
1133
+ console.log('│ \x1b[33mSlack / DM message:\x1b[0m │');
1134
+ console.log('│ │');
1135
+ for (const line of slackMsg.split('\n')) {
1136
+ console.log(`│ \x1b[90m${line.slice(0, 58).padEnd(58)}\x1b[0m │`);
1137
+ }
1138
+ console.log('│ │');
1139
+ console.log('├─────────────────────────────────────────────────────────────┤');
1140
+ console.log(`│ \x1b[36m𝕏 Tweet:\x1b[0m https://twitter.com/intent/tweet?text=${tweetText.slice(0, 10)}... │`);
1141
+ console.log('└─────────────────────────────────────────────────────────────┘');
1142
+ console.log('');
1143
+ console.log('\x1b[2m Each developer who signs up via your link earns you 1 month Pro.\x1b[0m');
1144
+ console.log('');
1145
+ }
1146
+ catch (e) {
1147
+ console.log(`\n❌ Could not fetch invite link: ${e.message}\n`);
1148
+ process.exit(1);
1149
+ }
1150
+ process.exit(0);
1151
+ }
1152
+ // ── CLI: cachly join <token> ──────────────────────────────────────────────────
1153
+ // Usage: npx @cachly-dev/mcp-server@latest join <token>
1154
+ // Accepts a Brain invite link — writes the shared instance ID into all
1155
+ // detected editor MCP configs and the global ~/.claude/mcp.json.
1156
+ // No account needed if CACHLY_JWT is already set; otherwise prompts for auth.
1157
+ if (process.argv[2] === 'join') {
1158
+ const { writeFile: wfJ, readFile: rfJ, mkdir: mkJ } = await import('node:fs/promises');
1159
+ const { existsSync: exJ } = await import('node:fs');
1160
+ const { resolve: resJ, dirname: dirJ } = await import('node:path');
1161
+ const { homedir } = await import('node:os');
1162
+ const { createInterface: ciJ } = await import('node:readline');
1163
+ const token = process.argv[3] ?? '';
1164
+ if (!token) {
1165
+ console.log('\n❌ Usage: npx @cachly-dev/mcp-server@latest join <token>\n');
1166
+ console.log(' Get a token from a teammate: npx @cachly-dev/mcp-server@latest invite\n');
1167
+ process.exit(1);
1168
+ }
1169
+ console.log('\n⏳ Looking up invite...');
1170
+ // ── Step 1: Fetch invite info (public, no auth) ───────────────────────────
1171
+ let inviteInfo;
1172
+ try {
1173
+ const res = await fetch(`${API_URL}/api/invite/${token}`, { signal: AbortSignal.timeout(8000) });
1174
+ if (res.status === 404) {
1175
+ console.log('\n❌ Invite not found — the link may be invalid.\n');
1176
+ process.exit(1);
1177
+ }
1178
+ if (res.status === 410) {
1179
+ console.log('\n❌ This invite link has expired (7-day limit).\n');
1180
+ process.exit(1);
1181
+ }
1182
+ if (!res.ok) {
1183
+ console.log(`\n❌ Could not fetch invite: HTTP ${res.status}\n`);
1184
+ process.exit(1);
1185
+ }
1186
+ inviteInfo = await res.json();
1187
+ }
1188
+ catch (e) {
1189
+ console.log(`\n❌ Network error: ${e.message}\n`);
1190
+ process.exit(1);
1191
+ }
1192
+ const { instance_id, instance_name, tier, label } = inviteInfo;
1193
+ const expires = new Date(inviteInfo.expires_at).toLocaleDateString();
1194
+ console.log('');
1195
+ console.log(' \x1b[35m╔══════════════════════════════════════════════════════╗\x1b[0m');
1196
+ console.log(' \x1b[35m║\x1b[0m \x1b[1m🧠 Brain Invite\x1b[0m \x1b[35m║\x1b[0m');
1197
+ console.log(' \x1b[35m╚══════════════════════════════════════════════════════╝\x1b[0m');
1198
+ console.log('');
1199
+ console.log(` Brain : \x1b[1m${instance_name}\x1b[0m`);
1200
+ console.log(` Tier : ${tier}`);
1201
+ if (label)
1202
+ console.log(` Note : ${label}`);
1203
+ console.log(` Expires : ${expires}`);
1204
+ console.log('');
1205
+ // ── Step 2: Confirm ───────────────────────────────────────────────────────
1206
+ const rlJ = ciJ({ input: process.stdin, output: process.stdout });
1207
+ const confirmJ = await new Promise((resolve) => {
1208
+ rlJ.question(' Join this Brain? [Y/n] ', (a) => { rlJ.close(); resolve(a.trim().toLowerCase() || 'y'); });
1209
+ });
1210
+ if (confirmJ !== 'y' && confirmJ !== 'yes') {
1211
+ console.log('\n Cancelled.\n');
1212
+ process.exit(0);
1213
+ }
1214
+ // ── Step 3: Ensure JWT ────────────────────────────────────────────────────
1215
+ let joinJwt = JWT;
1216
+ if (!joinJwt) {
1217
+ console.log('\n No API key found — starting sign-in (10 seconds)...\n');
1218
+ try {
1219
+ const dcRes = await fetch(`${API_URL}/api/v1/auth/device/code`, {
1220
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1221
+ body: JSON.stringify({ client_id: 'cachly-mcp-cli' }),
1222
+ signal: AbortSignal.timeout(10000),
1223
+ });
1224
+ if (dcRes.ok) {
1225
+ const dc = await dcRes.json();
1226
+ console.log(` 👉 Open: \x1b[36m${dc.verification_uri}\x1b[0m`);
1227
+ console.log(` Code: \x1b[1m${dc.user_code}\x1b[0m\n`);
1228
+ // Poll for token
1229
+ const pollInterval = (dc.interval ?? 5) * 1000;
1230
+ for (let i = 0; i < 24; i++) {
1231
+ await new Promise(r => setTimeout(r, pollInterval));
1232
+ try {
1233
+ const tokRes = await fetch(`${API_URL}/api/v1/auth/device/token`, {
1234
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1235
+ body: JSON.stringify({ device_code: dc.device_code, client_id: 'cachly-mcp-cli' }),
1236
+ signal: AbortSignal.timeout(8000),
1237
+ });
1238
+ if (tokRes.ok) {
1239
+ const tok = await tokRes.json();
1240
+ if (tok.access_token) {
1241
+ joinJwt = tok.access_token;
1242
+ break;
1243
+ }
1244
+ }
1245
+ }
1246
+ catch { /* polling — keep going */ }
1247
+ }
1248
+ if (!joinJwt) {
1249
+ console.log('\n❌ Sign-in timed out. Run the command again.\n');
1250
+ process.exit(1);
1251
+ }
1252
+ console.log(' ✅ Signed in!\n');
1253
+ }
1254
+ }
1255
+ catch (e) {
1256
+ console.log(`\n ⚠️ Could not start sign-in: ${e.message}`);
1257
+ console.log(` Set CACHLY_JWT manually from https://cachly.dev/setup-ai\n`);
1258
+ process.exit(1);
1259
+ }
1260
+ }
1261
+ // ── Step 4: Write instance ID into all editor MCP configs ────────────────
1262
+ const cwd = process.cwd();
1263
+ const home = homedir();
1264
+ const editorFiles = [
1265
+ { label: '.mcp.json (Claude Code project)', path: resJ(cwd, '.mcp.json') },
1266
+ { label: '.cursor/mcp.json', path: resJ(cwd, '.cursor', 'mcp.json') },
1267
+ { label: '.windsurf/mcp.json', path: resJ(cwd, '.windsurf', 'mcp.json') },
1268
+ { label: '.vscode/mcp.json (Copilot/Cline)', path: resJ(cwd, '.vscode', 'mcp.json') },
1269
+ { label: '~/.claude/mcp.json (global)', path: resJ(home, '.claude', 'mcp.json'), global: true },
1270
+ ];
1271
+ let written = 0;
1272
+ for (const { label: eLabel, path: ePath, global: isGlobal } of editorFiles) {
1273
+ // Only write project configs if the directory exists (or global)
1274
+ const dir = dirJ(ePath);
1275
+ if (!isGlobal && !exJ(dir) && !exJ(ePath))
1276
+ continue;
1277
+ try {
1278
+ await mkJ(dir, { recursive: true });
1279
+ let cfg = {};
1280
+ if (exJ(ePath)) {
1281
+ try {
1282
+ cfg = JSON.parse(await rfJ(ePath, 'utf-8'));
1283
+ }
1284
+ catch {
1285
+ try {
1286
+ await (await import('node:fs/promises')).rename(ePath, ePath + '.bak');
1287
+ }
1288
+ catch { /* backup failed */ }
1289
+ }
1290
+ }
1291
+ // Update or create the cachly entry with the shared instance ID
1292
+ const servers = (cfg['mcpServers'] ?? {});
1293
+ const existing = (servers['cachly'] ?? {});
1294
+ const env = (existing['env'] ?? {});
1295
+ env['CACHLY_BRAIN_INSTANCE_ID'] = instance_id;
1296
+ if (joinJwt && !env['CACHLY_JWT'])
1297
+ env['CACHLY_JWT'] = joinJwt;
1298
+ env['CACHLY_API_URL'] ??= 'https://api.cachly.dev';
1299
+ servers['cachly'] = { ...existing, command: 'npx', args: ['-y', '@cachly-dev/mcp-server@latest'], env };
1300
+ cfg['mcpServers'] = servers;
1301
+ await wfJ(ePath, JSON.stringify(cfg, null, 2), 'utf-8');
1302
+ console.log(` ✅ ${eLabel}`);
1303
+ written++;
1304
+ }
1305
+ catch (joinWriteErr) {
1306
+ // Only surface unexpected errors — missing editor dirs are expected and silently skipped above
1307
+ if (joinWriteErr.code !== 'ENOENT') {
1308
+ console.log(` ⚠️ ${eLabel}: ${joinWriteErr.message}`);
1309
+ }
1310
+ }
1311
+ }
1312
+ console.log('');
1313
+ if (written === 0) {
1314
+ console.log(' ⚠️ No editor configs found — run `cachly setup` first.\n');
1315
+ }
1316
+ else {
1317
+ console.log(` 🧠 You're now sharing Brain: \x1b[1m${instance_name}\x1b[0m`);
1318
+ console.log(' Restart your editor — your AI arrives pre-briefed from the team Brain.\n');
1319
+ console.log(' 📛 Add the badge to your README:');
1320
+ console.log(` npx @cachly-dev/mcp-server@latest badge\n`);
1321
+ }
1322
+ process.exit(0);
1323
+ }
1324
+ // ── CLI: cachly upgrade ───────────────────────────────────────────────────────
1325
+ // Usage: npx @cachly-dev/mcp-server@latest upgrade
1326
+ // Checks npm for the latest version and prints upgrade instructions if outdated.
1327
+ if (process.argv[2] === 'upgrade') {
1328
+ process.stdout.write('\n⏳ Checking for updates...\n');
1329
+ try {
1330
+ const res = await fetch('https://registry.npmjs.org/@cachly-dev/mcp-server/latest', {
1331
+ signal: AbortSignal.timeout(8000),
1332
+ });
1333
+ if (!res.ok)
1334
+ throw new Error(`registry HTTP ${res.status}`);
1335
+ const pkg = await res.json();
1336
+ const latest = pkg.version;
1337
+ if (!latest || !/^\d+\.\d+\.\d+/.test(latest))
1338
+ throw new Error('npm registry returned unexpected response');
1339
+ if (latest === CURRENT_VERSION) {
1340
+ console.log(`\n✅ Already on the latest version: \x1b[32mv${CURRENT_VERSION}\x1b[0m\n`);
1341
+ }
1342
+ else {
1343
+ console.log('');
1344
+ console.log('\x1b[35m ╔══════════════════════════════════════════════════════╗\x1b[0m');
1345
+ console.log('\x1b[35m ║\x1b[0m \x1b[1m🧠 cachly update available\x1b[0m \x1b[35m║\x1b[0m');
1346
+ console.log('\x1b[35m ╚══════════════════════════════════════════════════════╝\x1b[0m');
1347
+ console.log('');
1348
+ console.log(` Current : \x1b[31mv${CURRENT_VERSION}\x1b[0m`);
1349
+ console.log(` Latest : \x1b[32mv${latest}\x1b[0m`);
1350
+ console.log('');
1351
+ console.log(' Update your editor configs to pick up the new version:');
1352
+ console.log('');
1353
+ console.log(' \x1b[32m npx @cachly-dev/mcp-server@latest setup\x1b[0m');
1354
+ console.log('');
1355
+ console.log(' \x1b[2m(npx always fetches the latest when @latest is specified)\x1b[0m');
1356
+ console.log('');
1357
+ }
1358
+ }
1359
+ catch (e) {
1360
+ console.log(`\n❌ Could not check for updates: ${e.message}\n`);
1361
+ process.exit(1);
1362
+ }
1363
+ process.exit(0);
1364
+ }
1365
+ // ── CLI: cachly demo ──────────────────────────────────────────────────────────
1366
+ // Usage: npx @cachly-dev/mcp-server@latest demo
1367
+ // Zero-signup. Reads local git history and shows what the AI Brain would know.
1368
+ // The fastest path from "what is this?" to "I need this".
1369
+ if (process.argv[2] === 'demo') {
1370
+ const { execSync } = await import('node:child_process');
1371
+ const { existsSync, readFileSync } = await import('node:fs');
1372
+ const { resolve, basename } = await import('node:path');
1373
+ const cwd = process.cwd();
1374
+ console.log('\n\x1b[35m🧠 cachly Brain — Live Demo\x1b[0m');
1375
+ console.log('\x1b[90mNo account needed. Reading your git history...\x1b[0m\n');
1376
+ // Verify git repo
1377
+ if (!existsSync(resolve(cwd, '.git'))) {
1378
+ console.log('⚠️ No git repository found here.');
1379
+ console.log(' Run this command inside a project directory with git history.\n');
1380
+ console.log(' Example: cd ~/my-project && npx @cachly-dev/mcp-server@latest demo\n');
1381
+ process.exit(0);
1382
+ }
1383
+ // Get developer name from git config
1384
+ let devName = '';
1385
+ try {
1386
+ devName = execSync('git config user.name', { cwd, encoding: 'utf-8', stdio: 'pipe' }).trim().split(' ')[0] ?? '';
1387
+ }
1388
+ catch { /* ignore */ }
1389
+ // Get project name from package.json or directory name
1390
+ let projectName = basename(cwd);
1391
+ try {
1392
+ const pkg = JSON.parse(readFileSync(resolve(cwd, 'package.json'), 'utf-8'));
1393
+ if (pkg.name)
1394
+ projectName = pkg.name.replace(/^@[^/]+\//, '');
1395
+ }
1396
+ catch { /* ignore */ }
1397
+ // Read git log
1398
+ let logOutput = '';
1399
+ try {
1400
+ logOutput = execSync('git log HEAD --pretty=format:"%H|||%s|||%ad|||%an" --date=short --no-merges -n 200', { cwd, encoding: 'utf-8', stdio: 'pipe' });
1401
+ }
1402
+ catch {
1403
+ console.log('⚠️ Could not read git log. Make sure you have commits.\n');
1404
+ process.exit(0);
1405
+ }
1406
+ const commits = logOutput.trim().split('\n').filter(Boolean).map(line => {
1407
+ const [sha, subject, date, author] = line.split('|||');
1408
+ return { sha: (sha ?? '').trim().slice(0, 8), subject: (subject ?? '').trim(), date: (date ?? '').trim(), author: (author ?? '').trim() };
1409
+ });
1410
+ if (commits.length === 0) {
1411
+ console.log('⚠️ No commits found.\n');
1412
+ process.exit(0);
1413
+ }
1414
+ // Classify commits (same logic as brain_from_git)
1415
+ const classify = (s) => {
1416
+ const t = s.toLowerCase();
1417
+ if (/\b(fix|fixed|bug|hotfix|patch|revert|resolve)\b/.test(t))
1418
+ return 'fix';
1419
+ if (/\b(feat|feature|add|implement|new|introduce)\b/.test(t))
1420
+ return 'feat';
1421
+ if (/\b(perf|optim|speed|cache|latency)\b/.test(t))
1422
+ return 'perf';
1423
+ if (/\b(security|cve|auth|csrf|xss|inject|sanitize)\b/.test(t))
1424
+ return 'security';
1425
+ if (/\b(deploy|ci|cd|docker|k8s|infra)\b/.test(t))
1426
+ return 'deploy';
1427
+ if (/\b(refactor|clean|simplify|extract)\b/.test(t))
1428
+ return 'refactor';
1429
+ return 'chore';
1430
+ };
1431
+ // Count categories
1432
+ const cats = new Map();
1433
+ const fixes = [];
1434
+ const feats = [];
1435
+ const security = [];
1436
+ const authors = new Set();
1437
+ for (const c of commits) {
1438
+ const cat = classify(c.subject);
1439
+ cats.set(cat, (cats.get(cat) ?? 0) + 1);
1440
+ if (cat === 'fix' && fixes.length < 5)
1441
+ fixes.push(c.subject.slice(0, 72));
1442
+ if (cat === 'feat' && feats.length < 5)
1443
+ feats.push(c.subject.slice(0, 72));
1444
+ if (cat === 'security' && security.length < 3)
1445
+ security.push(c.subject.slice(0, 72));
1446
+ if (c.author)
1447
+ authors.add(c.author.split(' ')[0]);
1448
+ }
1449
+ const totalLessons = commits.length - (cats.get('chore') ?? 0);
1450
+ const dateRange = `${commits[commits.length - 1]?.date ?? '?'} → ${commits[0]?.date ?? '?'}`;
1451
+ // Estimate time wasted re-explaining (45 min/day * workdays since first commit)
1452
+ const firstDate = commits[commits.length - 1]?.date;
1453
+ let daysActive = 0;
1454
+ if (firstDate) {
1455
+ const ms = Date.now() - new Date(firstDate).getTime();
1456
+ daysActive = Math.max(1, Math.round(ms / (1000 * 60 * 60 * 24) * 5 / 7)); // workdays
1457
+ }
1458
+ const hoursWasted = Math.round(daysActive * 0.75); // 45 min/day
1459
+ // Brain Level based on total lessons (matches Go ComputeBrainLevel thresholds)
1460
+ const brainLevelName = totalLessons >= 501 ? 'Oracle' : totalLessons >= 201 ? 'Architect' : totalLessons >= 51 ? 'Expert' : totalLessons >= 11 ? 'Explorer' : 'Apprentice';
1461
+ // Personalized header
1462
+ const greeting = devName ? `Hey ${devName} — ` : '';
1463
+ const headerTitle = `${greeting}here's what your AI would know about ${projectName}`;
1464
+ // Display the demo
1465
+ process.stdout.write('\x1b[2K\r');
1466
+ console.log('┌─────────────────────────────────────────────────────────────┐');
1467
+ console.log(`│ \x1b[1m🧠 ${headerTitle.slice(0, 58).padEnd(58)}\x1b[0m│`);
1468
+ console.log('├─────────────────────────────────────────────────────────────┤');
1469
+ console.log(`│ Commits analysed : \x1b[33m${String(commits.length).padEnd(6)}\x1b[0m Date range: \x1b[90m${dateRange.slice(0, 23).padEnd(23)}\x1b[0m │`);
1470
+ console.log(`│ Lessons extracted: \x1b[32m${String(totalLessons).padEnd(6)}\x1b[0m Contributors: \x1b[36m${String(authors.size).padEnd(20)}\x1b[0m│`);
1471
+ console.log(`│ Brain Level : \x1b[35m${brainLevelName.padEnd(14)}\x1b[0m Time wasted re-explaining: \x1b[31m${String(hoursWasted + 'h').padEnd(5)}\x1b[0m│`);
1472
+ console.log('├─────────────────────────────────────────────────────────────┤');
1473
+ // Category breakdown
1474
+ const sorted = [...cats.entries()].filter(([k]) => k !== 'chore').sort((a, b) => b[1] - a[1]);
1475
+ const bar = (n, max) => '█'.repeat(Math.round((n / max) * 20)).padEnd(20);
1476
+ const maxVal = Math.max(...sorted.map(([, v]) => v), 1);
1477
+ console.log('│ \x1b[90mCategory breakdown:\x1b[0m │');
1478
+ for (const [cat, count] of sorted) {
1479
+ const emoji = { fix: '🔧', feat: '✨', perf: '⚡', security: '🔒', deploy: '🚀', refactor: '🔄' }[cat] ?? '•';
1480
+ console.log(`│ ${emoji} \x1b[36m${cat.padEnd(10)}\x1b[0m \x1b[35m${bar(count, maxVal)}\x1b[0m \x1b[33m${String(count).padStart(3)}\x1b[0m │`);
1481
+ }
1482
+ if (security.length > 0) {
1483
+ console.log('├─────────────────────────────────────────────────────────────┤');
1484
+ console.log('│ \x1b[31m🔒 Security fixes your AI would know:\x1b[0m │');
1485
+ for (const s of security)
1486
+ console.log(`│ \x1b[90m• ${s.slice(0, 58).padEnd(58)}\x1b[0m │`);
1487
+ }
1488
+ if (fixes.length > 0) {
1489
+ console.log('├─────────────────────────────────────────────────────────────┤');
1490
+ console.log('│ \x1b[33m🔧 Bug fixes your AI would never repeat:\x1b[0m │');
1491
+ for (const f of fixes.slice(0, 4))
1492
+ console.log(`│ \x1b[90m• ${f.slice(0, 58).padEnd(58)}\x1b[0m │`);
1493
+ }
1494
+ if (feats.length > 0) {
1495
+ console.log('├─────────────────────────────────────────────────────────────┤');
1496
+ console.log('│ \x1b[32m✨ Features & decisions it would recall:\x1b[0m │');
1497
+ for (const f of feats.slice(0, 3))
1498
+ console.log(`│ \x1b[90m• ${f.slice(0, 58).padEnd(58)}\x1b[0m │`);
1499
+ }
1500
+ console.log('├─────────────────────────────────────────────────────────────┤');
1501
+ console.log('│ \x1b[32m💡 With cachly, your AI arrives pre-briefed every session.\x1b[0m │');
1502
+ console.log('│ \x1b[32m No more re-explaining. No more repeated mistakes.\x1b[0m │');
1503
+ console.log('└─────────────────────────────────────────────────────────────┘');
1504
+ console.log('');
1505
+ // Generate shareable preview URL with encoded stats
1506
+ const previewParams = new URLSearchParams({
1507
+ repo: projectName,
1508
+ commits: String(commits.length),
1509
+ lessons: String(totalLessons),
1510
+ level: brainLevelName,
1511
+ authors: String(authors.size),
1512
+ hours: String(hoursWasted),
1513
+ });
1514
+ const previewURL = `https://cachly.dev/preview?${previewParams.toString()}`;
1515
+ console.log(' \x1b[1mMake this permanent (free, 1–5 minutes):\x1b[0m');
1516
+ console.log(' \x1b[32m$ npx @cachly-dev/mcp-server@latest setup\x1b[0m');
1517
+ console.log('');
1518
+ console.log(` \x1b[90m🔗 Shareable preview:\x1b[0m \x1b[36m${previewURL}\x1b[0m`);
1519
+ console.log('');
1520
+ console.log(' Works with: Claude Code · Cursor · Windsurf · Copilot · Cline · Zed');
1521
+ console.log(' Free forever · GDPR · German servers · No credit card');
1522
+ console.log('');
1523
+ process.exit(0);
1524
+ }
1525
+ // ── CLI: cachly share ─────────────────────────────────────────────────────────
1526
+ // Usage: npx @cachly-dev/mcp-server@latest share
1527
+ // Generates a shareable ASCII card + tweet text with your Brain stats.
1528
+ if (process.argv[2] === 'share') {
1529
+ const apiKey = process.env.CACHLY_JWT ?? '';
1530
+ const instanceId = process.env.CACHLY_BRAIN_INSTANCE_ID ?? '';
1531
+ if (!apiKey || !instanceId) {
1532
+ console.log('\n⚠️ CACHLY_JWT and CACHLY_BRAIN_INSTANCE_ID must be set.');
1533
+ console.log(' Run: npx @cachly-dev/mcp-server@latest setup (takes 1–5 minutes)\n');
1534
+ process.exit(1);
1535
+ }
1536
+ process.stdout.write('\n🧠 Fetching your Brain stats...\n');
1537
+ try {
1538
+ const statsRes = await fetch(`${API_URL}/api/v1/instances/${instanceId}/brain/stats`, {
1539
+ headers: { Authorization: `Bearer ${apiKey}` },
1540
+ signal: AbortSignal.timeout(8000),
1541
+ });
1542
+ if (!statsRes.ok)
1543
+ throw new Error(`HTTP ${statsRes.status}`);
1544
+ const stats = await statsRes.json();
1545
+ const lessons = stats.lesson_count ?? 0;
1546
+ const recalls = stats.total_recall_count ?? 0;
1547
+ const score = Math.round((stats.quality_score ?? 0) * 100);
1548
+ const tokensSaved = recalls * 1200;
1549
+ const costSaved = (tokensSaved * 0.000003).toFixed(2);
1550
+ const level = lessons === 0 ? 'Intern 🌱' :
1551
+ lessons < 10 ? 'Junior Dev 🔧' :
1552
+ lessons < 30 ? 'Mid Dev ⚡' :
1553
+ lessons < 60 ? 'Senior Dev 🧠' :
1554
+ lessons < 100 ? 'Staff Eng 🚀' : 'Principal Eng 🏆';
1555
+ console.log('');
1556
+ console.log('┌─────────────────────────────────────────────────────────────┐');
1557
+ console.log('│ 🧠 My AI Brain · powered by \x1b[35mcachly.dev\x1b[0m │');
1558
+ console.log('├─────────────────────────────────────────────────────────────┤');
1559
+ console.log(`│ Lessons stored : \x1b[33m${String(lessons).padEnd(8)}\x1b[0m Brain level: \x1b[32m${level.padEnd(18)}\x1b[0m│`);
1560
+ console.log(`│ Total recalls : \x1b[36m${String(recalls).padEnd(8)}\x1b[0m Quality score: \x1b[32m${String(score).padEnd(3)}%\x1b[0m │`);
1561
+ console.log(`│ Tokens saved : \x1b[32m${String((tokensSaved / 1000).toFixed(0) + 'K').padEnd(8)}\x1b[0m Cost saved: \x1b[32m$${costSaved.padEnd(20)}\x1b[0m│`);
1562
+ console.log('├─────────────────────────────────────────────────────────────┤');
1563
+ console.log('│ Works with: Claude Code · Cursor · Windsurf · Copilot │');
1564
+ console.log('│ Free · GDPR · German servers │');
1565
+ console.log('│ \x1b[35mnpx @cachly-dev/mcp-server@latest setup\x1b[0m │');
1566
+ console.log('└─────────────────────────────────────────────────────────────┘');
1567
+ console.log('');
1568
+ // Tweet text
1569
+ const shareUrl = 'https://cachly.dev?ref=share&utm_source=x&utm_medium=social&utm_campaign=cli-share';
1570
+ const tweet = `🧠 My AI coding assistant now has persistent memory.\n\n${lessons} lessons stored · ${recalls} recalls · ${level}\n\nFix a bug once → AI remembers forever. Across Claude Code, Cursor, Windsurf, Copilot.\n\n${shareUrl}\n\n#AIMemory #ClaudeCode #Cursor #DeveloperTools`;
1571
+ console.log(' \x1b[1m📋 Share on Twitter/X (copy this):\x1b[0m');
1572
+ console.log(' ─────────────────────────────────────');
1573
+ console.log(tweet.split('\n').map(l => ` ${l}`).join('\n'));
1574
+ console.log(' ─────────────────────────────────────');
1575
+ console.log('');
1576
+ console.log(' \x1b[2mPro tip: add a screenshot of your Brain panel for 3x more engagement\x1b[0m');
1577
+ console.log(' \x1b[2mAlso add a live badge to your README:\x1b[0m');
1578
+ console.log(` \x1b[90m npx @cachly-dev/mcp-server@latest badge\x1b[0m`);
1579
+ console.log('');
1580
+ }
1581
+ catch (e) {
1582
+ console.log(`\n❌ Could not fetch stats: ${e.message}\n`);
1583
+ process.exit(1);
1584
+ }
1585
+ process.exit(0);
1586
+ }
1587
+ // ── CLI: cachly badge ─────────────────────────────────────────────────────────
1588
+ // Outputs the Markdown + HTML snippet for embedding a live Brain lesson-count
1589
+ // badge in any README or website. Badge SVG served by cachly API (public, no auth).
1590
+ if (process.argv[2] === 'badge') {
1591
+ const instanceId = process.env.CACHLY_BRAIN_INSTANCE_ID ?? '';
1592
+ if (!instanceId) {
1593
+ console.log('\n⚠️ CACHLY_BRAIN_INSTANCE_ID must be set.');
1594
+ console.log(' Run: npx @cachly-dev/mcp-server@latest setup (takes 1–5 minutes)\n');
1595
+ process.exit(1);
1596
+ }
1597
+ const badgeUrl = `${API_URL}/api/v1/badge/${instanceId}`;
1598
+ const targetUrl = 'https://cachly.dev';
1599
+ const markdown = `[![cachly Brain](${badgeUrl})](${targetUrl})`;
1600
+ const htmlBadge = `<a href="${targetUrl}"><img src="${badgeUrl}" alt="cachly Brain" /></a>`;
1601
+ console.log('');
1602
+ console.log('\x1b[35m ╔═══════════════════════════════════════════════════════╗\x1b[0m');
1603
+ console.log('\x1b[35m ║\x1b[0m \x1b[1m🧠 cachly Brain Badge\x1b[0m \x1b[35m║\x1b[0m');
1604
+ console.log('\x1b[35m ╚═══════════════════════════════════════════════════════╝\x1b[0m');
1605
+ console.log('');
1606
+ console.log(' Paste into your README.md:');
1607
+ console.log('');
1608
+ console.log(` \x1b[32m${markdown}\x1b[0m`);
1609
+ console.log('');
1610
+ console.log(' ─────────────────────────────────────────────────────');
1611
+ console.log(' Or paste into HTML:');
1612
+ console.log('');
1613
+ console.log(` \x1b[36m${htmlBadge}\x1b[0m`);
1614
+ console.log('');
1615
+ console.log(' ─────────────────────────────────────────────────────');
1616
+ console.log(' Badge URL (live, updates every hour):');
1617
+ console.log(` \x1b[90m${badgeUrl}\x1b[0m`);
1618
+ console.log('');
1619
+ console.log(' \x1b[2mThe badge shows your current lesson count — no auth required,');
1620
+ console.log(' safe to embed in public repos. Updates hourly.\x1b[0m');
1621
+ console.log('');
1622
+ process.exit(0);
1623
+ }
1624
+ // ── CLI: no-args splash screen ────────────────────────────────────────────────
1625
+ // When run with no recognized subcommand, show a compelling pitch + help.
1626
+ if (!process.argv[2] && process.stdout.isTTY) {
1627
+ console.log('');
1628
+ console.log('\x1b[35m ╔═══════════════════════════════════════════════════════╗\x1b[0m');
1629
+ console.log('\x1b[35m ║\x1b[0m \x1b[1m🧠 cachly — Persistent AI Memory for Developers\x1b[0m \x1b[35m║\x1b[0m');
1630
+ console.log('\x1b[35m ╚═══════════════════════════════════════════════════════╝\x1b[0m');
1631
+ console.log('');
1632
+ console.log(' Stop re-teaching your AI every morning.');
1633
+ console.log(' cachly gives it a permanent brain — pre-briefed every session.');
1634
+ console.log('');
1635
+ console.log(' \x1b[1mCommands:\x1b[0m');
1636
+ console.log(' \x1b[32m npx @cachly-dev/mcp-server@latest demo\x1b[0m ← Start here (no account)');
1637
+ console.log(' \x1b[32m npx @cachly-dev/mcp-server@latest setup\x1b[0m ← Wire up your AI editors');
1638
+ console.log(' \x1b[36m npx @cachly-dev/mcp-server@latest health\x1b[0m ← Check everything works');
1639
+ console.log(' \x1b[36m npx @cachly-dev/mcp-server@latest digest\x1b[0m ← Weekly Brain summary');
1640
+ console.log(' \x1b[36m npx @cachly-dev/mcp-server@latest share\x1b[0m ← Share your Brain stats');
1641
+ console.log(' \x1b[36m npx @cachly-dev/mcp-server@latest badge\x1b[0m ← README badge for your Brain');
1642
+ console.log(' \x1b[36m npx @cachly-dev/mcp-server@latest invite\x1b[0m ← Invite a teammate');
1643
+ console.log(' \x1b[36m npx @cachly-dev/mcp-server@latest join <token>\x1b[0m ← Accept a Brain invite');
1644
+ console.log(' \x1b[36m npx @cachly-dev/mcp-server@latest upgrade\x1b[0m ← Check for updates');
1645
+ console.log('');
1646
+ console.log(' \x1b[90mWorks with: Claude Code · Cursor · Windsurf · GitHub Copilot · Cline · Zed\x1b[0m');
1647
+ console.log(' \x1b[90mFree forever · GDPR · German servers · 89 MCP tools\x1b[0m');
1648
+ console.log('');
1649
+ process.exit(0);
1650
+ }
958
1651
  // ── CLI: cachly init ──────────────────────────────────────────────────────────
959
1652
  // Usage: npx @cachly-dev/mcp-server init --instance-id <id> --api-key <key> [--editor claude|cursor|windsurf|copilot|continue] [--project-dir /path]
960
1653
  if (process.argv[2] === 'init') {
@@ -974,11 +1667,11 @@ if (process.argv[2] === 'init') {
974
1667
  }
975
1668
  const configFile = EDITOR_FILES[editor] ?? '.mcp.json';
976
1669
  const configPath = resolve(projectDir, configFile);
977
- const mergeResult = await writeMergedMcpConfig(configPath, apiKey, instanceId, editor);
978
- const verb = mergeResult === 'created' ? '✅ Written'
979
- : mergeResult === 'updated' ? '✅ Updated (kept existing servers)'
980
- : '✅ Merged into existing config (other servers untouched)';
981
- console.log(`\n${verb}: ${configFile}`);
1670
+ await mkdir(dirname(configPath), { recursive: true });
1671
+ const { existsSync: exInit } = await import('node:fs');
1672
+ const merged = await mergeMcpConfig(configPath, apiKey, instanceId, editor, { readFile, existsSync: exInit });
1673
+ await writeFile(configPath, merged, 'utf-8');
1674
+ console.log(`\n✅ ${exInit(configPath) ? 'Updated' : 'Written'}: ${configFile}`);
982
1675
  // Always write CLAUDE.md (idempotent — safe to run multiple times)
983
1676
  const result = await writeClaudeMd(projectDir, instanceId);
984
1677
  const action = result === 'updated' ? '✅ Updated' : result === 'appended' ? '✅ Appended to' : '✅ Written';
@@ -1025,7 +1718,279 @@ if (process.argv[2] === 'init') {
1025
1718
  }
1026
1719
  catch { /* non-critical — git hook is a best-effort feature */ }
1027
1720
  console.log(`\n🧠 Cachly AI Brain configured for ${editor === 'claude' ? 'Claude Code' : editor}!`);
1028
- console.log(` Restart your editor — the \`cachly\` MCP tools will appear.\n`);
1721
+ console.log(` Restart your editor — the \`cachly\` MCP tools will appear.`);
1722
+ console.log(`\n 📛 Add a live badge to your README:`);
1723
+ console.log(` npx @cachly-dev/mcp-server@latest badge\n`);
1724
+ process.exit(0);
1725
+ }
1726
+ // ── CLI: cachly health ────────────────────────────────────────────────────────
1727
+ // Usage: npx @cachly-dev/mcp-server@latest health
1728
+ // Checks: JWT valid, Brain API reachable, editor configs found, git hook present.
1729
+ // ── CLI: cachly status ────────────────────────────────────────────────────────
1730
+ // Usage: npx @cachly-dev/mcp-server@latest status
1731
+ // Shows Brain health, team size, and quick stats at a glance.
1732
+ if (process.argv[2] === 'status') {
1733
+ const apiKey = process.env.CACHLY_JWT ?? '';
1734
+ const instanceId = process.env.CACHLY_BRAIN_INSTANCE_ID ?? '';
1735
+ if (!apiKey || !instanceId) {
1736
+ console.log('\n⚠️ CACHLY_JWT and CACHLY_BRAIN_INSTANCE_ID must be set.');
1737
+ console.log(' Run: npx @cachly-dev/mcp-server@latest setup\n');
1738
+ process.exit(1);
1739
+ }
1740
+ try {
1741
+ const [statsRes, referralRes] = await Promise.all([
1742
+ fetch(`${API_URL}/api/v1/instances/${instanceId}/brain/stats`, {
1743
+ headers: { Authorization: `Bearer ${apiKey}` },
1744
+ signal: AbortSignal.timeout(8000),
1745
+ }),
1746
+ fetch(`${API_URL}/api/v1/referral/me`, {
1747
+ headers: { Authorization: `Bearer ${apiKey}` },
1748
+ signal: AbortSignal.timeout(8000),
1749
+ }),
1750
+ ]);
1751
+ if (!statsRes.ok)
1752
+ throw new Error(`stats HTTP ${statsRes.status}`);
1753
+ const stats = await statsRes.json();
1754
+ const referral = referralRes.ok
1755
+ ? await referralRes.json()
1756
+ : null;
1757
+ const lessons = stats.lesson_count ?? 0;
1758
+ const recalls = stats.total_recall_count ?? 0;
1759
+ const score = Math.round((stats.quality_score ?? 0) * 100);
1760
+ const team = stats.team_authors ?? [];
1761
+ const refCount = referral?.referral_count ?? 0;
1762
+ const refUrl = referral?.url ?? '';
1763
+ const level = lessons === 0 ? 'Intern 🌱' :
1764
+ lessons < 10 ? 'Junior Dev 🔧' :
1765
+ lessons < 30 ? 'Mid Dev ⚡' :
1766
+ lessons < 60 ? 'Senior Dev 🧠' :
1767
+ lessons < 100 ? 'Staff Eng 🚀' : 'Principal Eng 🏆';
1768
+ const statusIcon = lessons > 0 ? '\x1b[32m●\x1b[0m' : '\x1b[33m●\x1b[0m';
1769
+ console.log('');
1770
+ console.log('┌─────────────────────────────────────────────────────────────┐');
1771
+ console.log(`│ ${statusIcon} \x1b[1mBrain status\x1b[0m · instance ${instanceId.slice(0, 8)}... │`);
1772
+ console.log('├─────────────────────────────────────────────────────────────┤');
1773
+ console.log(`│ Lessons : \x1b[33m${String(lessons).padEnd(8)}\x1b[0m Level : \x1b[32m${level.padEnd(18)}\x1b[0m│`);
1774
+ console.log(`│ Recalls : \x1b[36m${String(recalls).padEnd(8)}\x1b[0m Quality score: \x1b[32m${String(score).padEnd(3)}%\x1b[0m │`);
1775
+ if (team.length > 0) {
1776
+ console.log('├─────────────────────────────────────────────────────────────┤');
1777
+ const teamStr = team.slice(0, 4).join(', ');
1778
+ console.log(`│ \x1b[36m👥 Team Brain\x1b[0m · ${String(team.length).padEnd(2)} developer${team.length === 1 ? ' ' : 's'} sharing lessons${' '.repeat(Math.max(0, 19 - teamStr.length))} │`);
1779
+ console.log(`│ ${teamStr.slice(0, 56).padEnd(56)} │`);
1780
+ }
1781
+ if (refUrl) {
1782
+ console.log('├─────────────────────────────────────────────────────────────┤');
1783
+ const refLine = `${refUrl.slice(0, 44)}${refUrl.length > 44 ? '…' : ''}`;
1784
+ console.log(`│ \x1b[35m🔗 Invite link\x1b[0m · ${refCount > 0 ? `\x1b[32m${refCount} joined\x1b[0m` : 'share to grow your team'}${' '.repeat(Math.max(0, 35 - String(refCount).length))} │`);
1785
+ console.log(`│ \x1b[2m${refLine.padEnd(56)}\x1b[0m │`);
1786
+ }
1787
+ console.log('└─────────────────────────────────────────────────────────────┘');
1788
+ console.log('');
1789
+ if (lessons === 0) {
1790
+ console.log(' \x1b[33m💡 No lessons yet.\x1b[0m Run a session with cachly connected to start learning.');
1791
+ console.log('');
1792
+ }
1793
+ }
1794
+ catch (e) {
1795
+ console.log(`\n❌ Could not fetch status: ${e.message}\n`);
1796
+ process.exit(1);
1797
+ }
1798
+ process.exit(0);
1799
+ }
1800
+ if (process.argv[2] === 'health') {
1801
+ const { existsSync } = await import('node:fs');
1802
+ const { readFile } = await import('node:fs/promises');
1803
+ const { resolve, join: pJoin } = await import('node:path');
1804
+ const cwd = process.cwd();
1805
+ let passed = 0;
1806
+ let failed = 0;
1807
+ const ok = (msg) => { console.log(` ✅ ${msg}`); passed++; };
1808
+ const warn = (msg) => { console.log(` ⚠️ ${msg}`); };
1809
+ const fail = (msg) => { console.log(` ❌ ${msg}`); failed++; };
1810
+ console.log('\n🧠 cachly health check\n');
1811
+ // ── 1. JWT token ────────────────────────────────────────────────────────────
1812
+ console.log('🔑 Auth token');
1813
+ const jwt = process.env.CACHLY_JWT ?? '';
1814
+ if (!jwt) {
1815
+ fail('CACHLY_JWT not set — run: npx @cachly-dev/mcp-server@latest setup');
1816
+ }
1817
+ else {
1818
+ try {
1819
+ const parts = jwt.split('.');
1820
+ if (parts.length !== 3)
1821
+ throw new Error('not a JWT');
1822
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
1823
+ const expMs = payload.exp ? payload.exp * 1000 : null;
1824
+ const minsLeft = expMs ? Math.floor((expMs - Date.now()) / 60_000) : null;
1825
+ if (expMs && expMs < Date.now()) {
1826
+ fail(`JWT expired ${Math.abs(minsLeft)} minute(s) ago — get a new one: https://cachly.dev/setup-ai`);
1827
+ }
1828
+ else if (minsLeft !== null && minsLeft < 30) {
1829
+ warn(`JWT expires in ${minsLeft} minute(s) — refresh soon at https://cachly.dev/setup-ai`);
1830
+ }
1831
+ else {
1832
+ ok(`JWT valid${minsLeft !== null ? ` (expires in ${Math.floor(minsLeft / 60)}h ${minsLeft % 60}m)` : ''}`);
1833
+ }
1834
+ }
1835
+ catch {
1836
+ fail('CACHLY_JWT format invalid (expected JWT with 3 parts)');
1837
+ }
1838
+ }
1839
+ // ── 2. Brain API reachability ───────────────────────────────────────────────
1840
+ console.log('\n🌐 Brain API');
1841
+ const apiUrl = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
1842
+ try {
1843
+ const res = await fetch(`${apiUrl}/health`, { signal: AbortSignal.timeout(5000) });
1844
+ if (res.ok) {
1845
+ const body = await res.json();
1846
+ ok(`${apiUrl} reachable (status: ${body.status ?? 'ok'})`);
1847
+ }
1848
+ else if (res.status === 401 || res.status === 403) {
1849
+ ok(`${apiUrl} reachable (auth required — expected)`);
1850
+ }
1851
+ else {
1852
+ fail(`${apiUrl}/health returned HTTP ${res.status}`);
1853
+ }
1854
+ }
1855
+ catch (e) {
1856
+ fail(`${apiUrl} unreachable — ${e.message}`);
1857
+ }
1858
+ // ── 3. Brain instance ───────────────────────────────────────────────────────
1859
+ console.log('\n🧠 Brain instance');
1860
+ const instanceId = process.env.CACHLY_BRAIN_INSTANCE_ID ?? '';
1861
+ if (!instanceId) {
1862
+ warn('CACHLY_BRAIN_INSTANCE_ID not set — will auto-resolve on first tool call');
1863
+ }
1864
+ else if (jwt) {
1865
+ try {
1866
+ const instRes = await fetch(`${apiUrl}/api/v1/instances/${instanceId}`, {
1867
+ headers: { Authorization: `Bearer ${jwt}`, Accept: 'application/json' },
1868
+ signal: AbortSignal.timeout(5000),
1869
+ });
1870
+ if (instRes.ok) {
1871
+ const inst = await instRes.json();
1872
+ if (inst.status === 'running') {
1873
+ ok(`Instance "${inst.name}" (${instanceId.slice(0, 8)}…) is running`);
1874
+ // Attempt actual Redis PING to verify connectivity end-to-end
1875
+ console.log('\n🔌 Redis connectivity');
1876
+ if (inst.host && inst.port) {
1877
+ try {
1878
+ let pingPassword;
1879
+ let pingTls = inst.tls_enabled !== false;
1880
+ try {
1881
+ const connRes = await fetch(`${apiUrl}/api/v1/instances/${instanceId}/connection`, {
1882
+ headers: { Authorization: `Bearer ${jwt}`, Accept: 'application/json' },
1883
+ signal: AbortSignal.timeout(5000),
1884
+ });
1885
+ if (connRes.ok) {
1886
+ const conn = await connRes.json();
1887
+ pingPassword = conn.password;
1888
+ pingTls = conn.tls_enabled !== false;
1889
+ }
1890
+ }
1891
+ catch { /* proceed without password */ }
1892
+ const pingClient = new Redis({
1893
+ host: inst.host, port: inst.port,
1894
+ password: pingPassword,
1895
+ ...(pingTls ? { tls: {} } : {}),
1896
+ lazyConnect: true, connectTimeout: 5000, retryStrategy: () => null,
1897
+ });
1898
+ await pingClient.connect();
1899
+ const pong = await pingClient.ping();
1900
+ pingClient.disconnect();
1901
+ if (pong === 'PONG') {
1902
+ ok(`Redis PING → PONG (${inst.host}:${inst.port})`);
1903
+ }
1904
+ else {
1905
+ warn(`Redis responded but not PONG: ${pong}`);
1906
+ }
1907
+ }
1908
+ catch (pingErr) {
1909
+ fail(`Redis unreachable — ${pingErr.message}`);
1910
+ }
1911
+ }
1912
+ else {
1913
+ warn('Instance has no host/port yet');
1914
+ }
1915
+ }
1916
+ else {
1917
+ warn(`Instance "${inst.name}" status: ${inst.status}`);
1918
+ }
1919
+ }
1920
+ else {
1921
+ fail(`Instance ${instanceId.slice(0, 8)}… not found (HTTP ${instRes.status})`);
1922
+ }
1923
+ }
1924
+ catch (e) {
1925
+ fail(`Could not check instance — ${e.message}`);
1926
+ }
1927
+ }
1928
+ // ── 4. Editor configs ───────────────────────────────────────────────────────
1929
+ console.log('\n🛠 Editor configs (project dir)');
1930
+ const editorConfigs = [
1931
+ { editor: 'Claude Code', path: '.mcp.json' },
1932
+ { editor: 'Cursor', path: '.cursor/mcp.json' },
1933
+ { editor: 'Windsurf', path: '.windsurf/mcp.json' },
1934
+ { editor: 'Copilot / Cline', path: '.vscode/mcp.json' },
1935
+ { editor: 'Zed', path: '.zed/settings.json' },
1936
+ { editor: 'Continue.dev', path: '.continue/config.json' },
1937
+ ];
1938
+ let configsFound = 0;
1939
+ for (const { editor, path } of editorConfigs) {
1940
+ const fullPath = resolve(cwd, path);
1941
+ if (existsSync(fullPath)) {
1942
+ try {
1943
+ const content = await readFile(fullPath, 'utf-8');
1944
+ const hasCachly = content.includes('@cachly-dev/mcp-server') || content.includes('cachly');
1945
+ if (hasCachly) {
1946
+ ok(`${editor}: ${path}`);
1947
+ configsFound++;
1948
+ }
1949
+ else {
1950
+ warn(`${editor}: ${path} exists but cachly not found inside`);
1951
+ }
1952
+ }
1953
+ catch {
1954
+ warn(`${editor}: ${path} exists but could not read`);
1955
+ }
1956
+ }
1957
+ }
1958
+ if (configsFound === 0) {
1959
+ fail('No editor MCP configs found — run: npx @cachly-dev/mcp-server@latest setup');
1960
+ }
1961
+ // ── 5. Git hook ─────────────────────────────────────────────────────────────
1962
+ console.log('\n🪝 Git hook (ambient learning)');
1963
+ const hookPath = resolve(cwd, '.git', 'hooks', 'post-commit');
1964
+ if (!existsSync(resolve(cwd, '.git'))) {
1965
+ warn('Not in a git repository — skip');
1966
+ }
1967
+ else if (existsSync(hookPath)) {
1968
+ try {
1969
+ const hook = await readFile(hookPath, 'utf-8');
1970
+ if (hook.includes('cachly')) {
1971
+ ok('.git/hooks/post-commit (cachly CLS hook present)');
1972
+ }
1973
+ else {
1974
+ warn('.git/hooks/post-commit exists but cachly hook not found — run setup to add it');
1975
+ }
1976
+ }
1977
+ catch {
1978
+ warn('.git/hooks/post-commit exists but could not read');
1979
+ }
1980
+ }
1981
+ else {
1982
+ warn('.git/hooks/post-commit not found — run: npx @cachly-dev/mcp-server@latest setup');
1983
+ }
1984
+ // ── Summary ─────────────────────────────────────────────────────────────────
1985
+ console.log(`\n${'─'.repeat(40)}`);
1986
+ if (failed === 0) {
1987
+ console.log(`✅ All checks passed (${passed} ok)\n`);
1988
+ }
1989
+ else {
1990
+ console.log(`❌ ${failed} check(s) failed, ${passed} passed\n`);
1991
+ console.log(`💡 Fix issues with: npx @cachly-dev/mcp-server@latest setup\n`);
1992
+ process.exit(1);
1993
+ }
1029
1994
  process.exit(0);
1030
1995
  }
1031
1996
  // ── CLI: cachly setup (interactive — no flags required) ───────────────────────
@@ -1035,6 +2000,7 @@ if (process.argv[2] === 'setup') {
1035
2000
  const { existsSync } = await import('node:fs');
1036
2001
  const { resolve, dirname } = await import('node:path');
1037
2002
  const { createInterface } = await import('node:readline');
2003
+ sendFunnelEvent('setup_started');
1038
2004
  // --yes / -y → non-interactive mode (skips all prompts, picks defaults)
1039
2005
  const nonInteractive = process.argv.includes('--yes') || process.argv.includes('-y');
1040
2006
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -1281,18 +2247,23 @@ if (process.argv[2] === 'setup') {
1281
2247
  const editorsToSetup = detected;
1282
2248
  console.log(`Step 3: Configuring for: ${editorsToSetup.map(editorLabel).join(', ')}\n`);
1283
2249
  // ── Step 4: Write editor configs (project-level) ─────────────────────────
1284
- // Critical: NEVER overwrite existing configs blindly — merge cachly into the
1285
- // existing mcpServers map so the user's other MCP servers (filesystem,
1286
- // github, postgres, …) are preserved. writeMergedMcpConfig also drops a
1287
- // .cachly-backup file next to the original for one-step recovery.
2250
+ // Uses mergeMcpConfig to preserve all existing MCP servers — only adds/updates
2251
+ // the cachly entry without touching filesystem, github, or other servers.
2252
+ const { existsSync: exSetup } = await import('node:fs');
1288
2253
  for (const editor of editorsToSetup) {
1289
2254
  const configFile = EDITOR_FILES[editor] ?? '.mcp.json';
1290
2255
  const configPath = resolve(cwd, configFile);
1291
- const result = await writeMergedMcpConfig(configPath, token, instance.id, editor);
1292
- const verb = result === 'created' ? '✅ Written'
1293
- : result === 'updated' ? '✅ Updated (kept existing servers)'
1294
- : '✅ Merged (other servers untouched)';
1295
- console.log(`${verb}: ${configFile}`);
2256
+ try {
2257
+ await mkdir(dirname(configPath), { recursive: true });
2258
+ const wasExisting = exSetup(configPath);
2259
+ const merged = await mergeMcpConfig(configPath, token, instance.id, editor, { readFile, existsSync: exSetup });
2260
+ await writeFile(configPath, merged, 'utf-8');
2261
+ console.log(`✅ ${wasExisting ? 'Updated' : 'Written'}: ${configFile}`);
2262
+ }
2263
+ catch (writeErr) {
2264
+ console.log(`⚠️ Could not write ${configFile}: ${writeErr.message}`);
2265
+ console.log(` Fix permissions or run with sudo, then re-run setup.`);
2266
+ }
1296
2267
  }
1297
2268
  // ── Step 4b: Write global Claude Code config (~/.claude/mcp.json) ─────────
1298
2269
  // Merges cachly into the existing global config so it works in every project,
@@ -1332,8 +2303,8 @@ if (process.argv[2] === 'setup') {
1332
2303
  JWT = token; // set global JWT so handleTool can authenticate
1333
2304
  const gitResult = await handleTool('brain_from_git', {
1334
2305
  instance_id: instance.id,
1335
- dir: cwd,
1336
- max_commits: 100,
2306
+ repo_path: cwd,
2307
+ limit: 100,
1337
2308
  });
1338
2309
  const match = gitResult.match(/(\d+) lesson/);
1339
2310
  console.log(` ✓ ${match ? match[1] + ' lessons' : 'done'} extracted from git history`);
@@ -1413,9 +2384,12 @@ if (process.argv[2] === 'setup') {
1413
2384
  catch {
1414
2385
  console.log(' (skipped)');
1415
2386
  }
1416
- console.log(`\n🧠 Done! Restart your editor — the \`cachly\` MCP tools will appear.`);
1417
- console.log(` Your AI now has persistent memory across every session.\n`);
1418
- console.log(` Dashboard: https://cachly.dev/instances/${instance.id}\n`);
2387
+ console.log(`\n🎉 Your AI just got a permanent brain.`);
2388
+ console.log(` Restart your editor — from this session on, it arrives pre-briefed.\n`);
2389
+ console.log(` No more re-explaining your stack. No repeated mistakes.\n`);
2390
+ console.log(` Dashboard: https://cachly.dev/instances/${instance.id}`);
2391
+ console.log(`\n 📛 Add a live badge to your README:`);
2392
+ console.log(` npx @cachly-dev/mcp-server@latest badge\n`);
1419
2393
  // ── Step 7: Email opt-in (non-blocking — at the very end) ────────────────
1420
2394
  if (!nonInteractive) {
1421
2395
  const email = await ask(' 📬 Stay in the loop? Email for release notes [Enter to skip]: ');
@@ -1468,7 +2442,12 @@ if (process.argv[2] === 'setup') {
1468
2442
  }
1469
2443
  }
1470
2444
  catch { /* non-critical */ }
2445
+ // Setup reached the end successfully — close the funnel.
2446
+ JWT = token;
2447
+ sendFunnelEvent('setup_completed', { instance_id: instance.id });
1471
2448
  rl.close();
2449
+ // Give the fire-and-forget telemetry a moment to flush before exit.
2450
+ await new Promise(r => setTimeout(r, 300));
1472
2451
  process.exit(0);
1473
2452
  }
1474
2453
  // ── CLI: cachly index <dir> ───────────────────────────────────────────────────
@@ -1508,11 +2487,53 @@ if (process.argv[2] === 'index') {
1508
2487
  }
1509
2488
  process.exit(0);
1510
2489
  }
2490
+ // ── CLI: cachly learn-git ─────────────────────────────────────────────────────
2491
+ // Usage: npx @cachly-dev/mcp-server@latest learn-git [./repo] [--max-commits 50]
2492
+ // Auto-learns brain lessons from recent git commits — ideal for CI on PR merge.
2493
+ // Each meaningful commit becomes a lesson, so the Brain grows with zero manual work.
2494
+ if (process.argv[2] === 'learn-git') {
2495
+ const { resolve } = await import('node:path');
2496
+ const argv = process.argv.slice(3);
2497
+ const flag = (name) => { const i = argv.indexOf(`--${name}`); return i !== -1 ? argv[i + 1] : undefined; };
2498
+ const repoDir = resolve(flag('dir') ?? argv.find(a => !a.startsWith('--')) ?? '.');
2499
+ const instanceId = flag('instance-id') ?? process.env.CACHLY_BRAIN_INSTANCE_ID;
2500
+ const maxCommits = parseInt(flag('max-commits') ?? '50', 10);
2501
+ if (!instanceId || !JWT) {
2502
+ console.error('\n❌ CACHLY_BRAIN_INSTANCE_ID and CACHLY_JWT must be set\n');
2503
+ console.error(' export CACHLY_BRAIN_INSTANCE_ID=<uuid>');
2504
+ console.error(' export CACHLY_JWT=<cky_live_...>');
2505
+ console.error(' npx @cachly-dev/mcp-server@latest learn-git .\n');
2506
+ process.exit(1);
2507
+ }
2508
+ console.log(`\n🧠 Learning from git history: ${repoDir}`);
2509
+ console.log(` Instance: ${instanceId.slice(0, 8)}… Max commits: ${maxCommits}\n`);
2510
+ try {
2511
+ const result = await handleTool('brain_from_git', {
2512
+ instance_id: instanceId,
2513
+ repo_path: repoDir,
2514
+ limit: maxCommits,
2515
+ });
2516
+ console.log(result);
2517
+ sendFunnelEvent('brain_from_git', { api_key: JWT, instance_id: instanceId });
2518
+ console.log('\n✅ Brain learned from your commits.\n');
2519
+ // Let fire-and-forget telemetry flush before exit.
2520
+ await new Promise(r => setTimeout(r, 300));
2521
+ }
2522
+ catch (err) {
2523
+ console.error(`\n❌ learn-git failed: ${err.message}\n`);
2524
+ process.exit(1);
2525
+ }
2526
+ process.exit(0);
2527
+ }
1511
2528
  // ── Start ─────────────────────────────────────────────────────────────────────
1512
2529
  // Warn on stderr when credentials are missing so the user sees a clear
1513
2530
  // actionable message in their editor's MCP log instead of silent failures.
1514
- if (!JWT) {
1515
- process.stderr.write('\n' +
2531
+ // Skip for CLI commands that intentionally run without credentials.
2532
+ const _cliNoAuthCommands = ['demo', 'share', 'health', 'setup', 'init', 'digest', 'invite', 'badge', 'join', 'upgrade'];
2533
+ if (!JWT && !_cliNoAuthCommands.includes(process.argv[2] ?? '') && !(!process.argv[2] && process.stdout.isTTY)) {
2534
+ // No args + no JWT in a non-TTY context = running as MCP server without credentials.
2535
+ // Print actionable setup banner to stdout so it's captured by callers/tests.
2536
+ const banner = '\n' +
1516
2537
  '╔══════════════════════════════════════════════════════════════════╗\n' +
1517
2538
  '║ 🧠 cachly AI Brain — Setup required ║\n' +
1518
2539
  '╠══════════════════════════════════════════════════════════════════╣\n' +
@@ -1527,7 +2548,16 @@ if (!JWT) {
1527
2548
  '║ ║\n' +
1528
2549
  '║ Free tier — no credit card required. ║\n' +
1529
2550
  '╚══════════════════════════════════════════════════════════════════╝\n' +
1530
- '\n');
2551
+ '\n';
2552
+ // When invoked with no args as the main entry point, write to stdout + exit cleanly.
2553
+ // When used as MCP server (args present), write to stderr so editors see it.
2554
+ if (!process.argv[2] && _isMain) {
2555
+ process.stdout.write(banner);
2556
+ process.exit(0);
2557
+ }
2558
+ else {
2559
+ process.stderr.write(banner);
2560
+ }
1531
2561
  }
1532
2562
  else {
1533
2563
  // Warn if the JWT is already expired or expiring within the hour.