@cachly-dev/mcp-server 0.5.41 → 0.5.43
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.
- package/dist/index.js +483 -8
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +505 -6
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
4
4
|
import { join, relative, extname } from 'node:path';
|
|
5
5
|
/**
|
|
6
|
-
* cachly MCP Server v0.5.
|
|
6
|
+
* cachly MCP Server v0.5.43
|
|
7
7
|
*
|
|
8
8
|
* Exposes cachly.dev as MCP tools so any AI assistant
|
|
9
9
|
* (GitHub Copilot, Claude, Cursor, Windsurf, Continue.dev …) can:
|
|
@@ -1391,6 +1391,52 @@ const TOOLS = [
|
|
|
1391
1391
|
required: ['instance_id'],
|
|
1392
1392
|
},
|
|
1393
1393
|
},
|
|
1394
|
+
{
|
|
1395
|
+
name: 'brain_stats',
|
|
1396
|
+
description: 'Complete statistics dashboard for your AI Brain — lessons, recalls, time saved, top topics, team authors, memory usage. ' +
|
|
1397
|
+
'Returns a formatted report designed for sharing (screenshot-worthy). ' +
|
|
1398
|
+
'Use this when you want a full overview or want to share your brain health with others.',
|
|
1399
|
+
inputSchema: {
|
|
1400
|
+
type: 'object',
|
|
1401
|
+
properties: {
|
|
1402
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1403
|
+
},
|
|
1404
|
+
required: ['instance_id'],
|
|
1405
|
+
},
|
|
1406
|
+
},
|
|
1407
|
+
{
|
|
1408
|
+
name: 'export_brain',
|
|
1409
|
+
description: 'Export your entire AI Brain as a portable Markdown file. ' +
|
|
1410
|
+
'Includes all lessons (topic, outcome, what worked, severity), context entries (keys + content), ' +
|
|
1411
|
+
'session history, and metadata. Perfect for sharing with teammates, archiving, posting to GitHub, ' +
|
|
1412
|
+
'or importing into a new instance. ' +
|
|
1413
|
+
'Example: export_brain() → returns full markdown string you can save to a .md file.',
|
|
1414
|
+
inputSchema: {
|
|
1415
|
+
type: 'object',
|
|
1416
|
+
properties: {
|
|
1417
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1418
|
+
include_context: { type: 'boolean', description: 'Include context entries (default: true)' },
|
|
1419
|
+
max_lessons: { type: 'number', description: 'Max lessons to include (default: all)' },
|
|
1420
|
+
},
|
|
1421
|
+
required: ['instance_id'],
|
|
1422
|
+
},
|
|
1423
|
+
},
|
|
1424
|
+
{
|
|
1425
|
+
name: 'invite_link',
|
|
1426
|
+
description: 'Generate a one-click team invite link for your Brain instance. ' +
|
|
1427
|
+
'Share the link (or the npx join command) with a teammate — they run one command and are instantly ' +
|
|
1428
|
+
'connected to the same shared Brain with all your lessons and context. ' +
|
|
1429
|
+
'No dashboard visit, no copy-paste of instance IDs required. ' +
|
|
1430
|
+
'Example: invite_link() → returns "npx @cachly-dev/mcp-server@latest join <token>"',
|
|
1431
|
+
inputSchema: {
|
|
1432
|
+
type: 'object',
|
|
1433
|
+
properties: {
|
|
1434
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance to share' },
|
|
1435
|
+
label: { type: 'string', description: 'Optional label shown to the invitee (e.g. "my-api project")' },
|
|
1436
|
+
},
|
|
1437
|
+
required: ['instance_id'],
|
|
1438
|
+
},
|
|
1439
|
+
},
|
|
1394
1440
|
{
|
|
1395
1441
|
name: 'brain_doctor',
|
|
1396
1442
|
description: 'Check the health of your AI Brain and get actionable recommendations. ' +
|
|
@@ -2952,6 +2998,275 @@ async function handleTool(name, args) {
|
|
|
2952
2998
|
}
|
|
2953
2999
|
return lines.join('\n');
|
|
2954
3000
|
}
|
|
3001
|
+
// ── brain_stats ───────────────────────────────────────────────────────────
|
|
3002
|
+
case 'brain_stats': {
|
|
3003
|
+
const { instance_id } = args;
|
|
3004
|
+
const redis = await getConnection(instance_id);
|
|
3005
|
+
// Scan all lesson keys
|
|
3006
|
+
const lessonKeys = [];
|
|
3007
|
+
const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
|
|
3008
|
+
await new Promise((resolve, reject) => {
|
|
3009
|
+
lStream.on('data', (batch) => lessonKeys.push(...batch));
|
|
3010
|
+
lStream.on('end', resolve);
|
|
3011
|
+
lStream.on('error', reject);
|
|
3012
|
+
});
|
|
3013
|
+
const lessons = [];
|
|
3014
|
+
if (lessonKeys.length > 0) {
|
|
3015
|
+
const vals = await redis.mget(...lessonKeys);
|
|
3016
|
+
for (const raw of vals) {
|
|
3017
|
+
if (!raw)
|
|
3018
|
+
continue;
|
|
3019
|
+
try {
|
|
3020
|
+
lessons.push(JSON.parse(raw));
|
|
3021
|
+
}
|
|
3022
|
+
catch { /* skip */ }
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
// Context count
|
|
3026
|
+
let ctxCount = 0;
|
|
3027
|
+
const ctxStream = redis.scanStream({ match: 'cachly:ctx:*', count: 200 });
|
|
3028
|
+
await new Promise((resolve, reject) => {
|
|
3029
|
+
ctxStream.on('data', (batch) => { ctxCount += batch.filter((k) => !k.endsWith(':meta')).length; });
|
|
3030
|
+
ctxStream.on('end', resolve);
|
|
3031
|
+
ctxStream.on('error', reject);
|
|
3032
|
+
});
|
|
3033
|
+
const lastSessionRaw = await redis.get('cachly:session:last');
|
|
3034
|
+
let lastSession = null;
|
|
3035
|
+
if (lastSessionRaw) {
|
|
3036
|
+
try {
|
|
3037
|
+
lastSession = JSON.parse(lastSessionRaw);
|
|
3038
|
+
}
|
|
3039
|
+
catch { /* ignore */ }
|
|
3040
|
+
}
|
|
3041
|
+
const totalRecalls = lessons.reduce((s, l) => s + (l.recall_count ?? 0), 0);
|
|
3042
|
+
const savedMin = totalRecalls * 15;
|
|
3043
|
+
const savedHours = (savedMin / 60).toFixed(1);
|
|
3044
|
+
const successCount = lessons.filter(l => l.outcome === 'success').length;
|
|
3045
|
+
const failureCount = lessons.filter(l => l.outcome === 'failure' || l.outcome === 'partial').length;
|
|
3046
|
+
const criticalCount = lessons.filter(l => l.severity === 'critical').length;
|
|
3047
|
+
const majorCount = lessons.filter(l => l.severity === 'major').length;
|
|
3048
|
+
const authors = [...new Set(lessons.map(l => l.author).filter(Boolean))];
|
|
3049
|
+
const topByRecall = [...lessons].sort((a, b) => (b.recall_count ?? 0) - (a.recall_count ?? 0)).slice(0, 5);
|
|
3050
|
+
const newestLesson = [...lessons].sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime())[0];
|
|
3051
|
+
// Age of last session
|
|
3052
|
+
let lastSessionStr = 'none yet';
|
|
3053
|
+
if (lastSession) {
|
|
3054
|
+
const ageMin = Math.round((Date.now() - new Date(lastSession.ts).getTime()) / 60000);
|
|
3055
|
+
lastSessionStr = ageMin < 60 ? `${ageMin}m ago`
|
|
3056
|
+
: ageMin < 1440 ? `${Math.round(ageMin / 60)}h ago`
|
|
3057
|
+
: `${Math.round(ageMin / 1440)}d ago`;
|
|
3058
|
+
}
|
|
3059
|
+
const tokensSaved = (totalRecalls * 1200);
|
|
3060
|
+
const tokenStr = tokensSaved >= 1000 ? `~${(tokensSaved / 1000).toFixed(0)}k tokens` : `~${tokensSaved} tokens`;
|
|
3061
|
+
const costSaved = (tokensSaved * 0.000003).toFixed(2);
|
|
3062
|
+
const lines = [
|
|
3063
|
+
`🧠 **Brain Stats — Full Dashboard**`,
|
|
3064
|
+
``,
|
|
3065
|
+
`╔══════════════════════════════════════╗`,
|
|
3066
|
+
`║ 📚 Lessons: ${String(lessons.length).padEnd(18)}║`,
|
|
3067
|
+
`║ 🔄 Total recalls: ${String(totalRecalls).padEnd(18)}║`,
|
|
3068
|
+
`║ 🕐 Time saved: ~${savedHours}h${' '.repeat(Math.max(0, 16 - savedHours.length))}║`,
|
|
3069
|
+
`║ 💎 Tokens saved: ${tokenStr.padEnd(18)}║`,
|
|
3070
|
+
`║ 💵 Cost saved: ~$${costSaved.padEnd(17)}║`,
|
|
3071
|
+
`║ 📁 Context keys: ${String(ctxCount).padEnd(18)}║`,
|
|
3072
|
+
`╚══════════════════════════════════════╝`,
|
|
3073
|
+
``,
|
|
3074
|
+
`**Lessons breakdown:**`,
|
|
3075
|
+
` ✅ Success: ${successCount} ❌ Failures/partial: ${failureCount}`,
|
|
3076
|
+
` 🔴 Critical: ${criticalCount} 🟡 Major: ${majorCount}`,
|
|
3077
|
+
``,
|
|
3078
|
+
`**Last session:** ${lastSessionStr}${lastSession?.summary ? ` — ${lastSession.summary.slice(0, 80)}` : ''}`,
|
|
3079
|
+
`**Newest lesson:** ${newestLesson ? `\`${newestLesson.topic}\`` : 'none'}`,
|
|
3080
|
+
``,
|
|
3081
|
+
];
|
|
3082
|
+
if (topByRecall.length > 0) {
|
|
3083
|
+
lines.push(`**Top recalled lessons (most reused):**`);
|
|
3084
|
+
for (const l of topByRecall) {
|
|
3085
|
+
const emoji = l.outcome === 'success' ? '✅' : '⚠️';
|
|
3086
|
+
lines.push(` ${emoji} \`${l.topic}\` — recalled ${l.recall_count ?? 0}× · ${l.what_worked.slice(0, 60)}`);
|
|
3087
|
+
}
|
|
3088
|
+
lines.push('');
|
|
3089
|
+
}
|
|
3090
|
+
if (authors.length > 0) {
|
|
3091
|
+
lines.push(`**Team contributors (${authors.length}):** ${authors.join(', ')}`);
|
|
3092
|
+
lines.push('');
|
|
3093
|
+
}
|
|
3094
|
+
lines.push(`💡 Share your brain: \`invite_link()\` — generates a one-command team invite.`);
|
|
3095
|
+
lines.push(`📤 Export to Markdown: \`export_brain()\` — portable snapshot you can save or share.`);
|
|
3096
|
+
return lines.join('\n');
|
|
3097
|
+
}
|
|
3098
|
+
// ── export_brain ──────────────────────────────────────────────────────────
|
|
3099
|
+
case 'export_brain': {
|
|
3100
|
+
const { instance_id, include_context = true, max_lessons } = args;
|
|
3101
|
+
const redis = await getConnection(instance_id);
|
|
3102
|
+
// Scan all lessons
|
|
3103
|
+
const lessonKeys = [];
|
|
3104
|
+
const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
|
|
3105
|
+
await new Promise((resolve, reject) => {
|
|
3106
|
+
lStream.on('data', (batch) => lessonKeys.push(...batch));
|
|
3107
|
+
lStream.on('end', resolve);
|
|
3108
|
+
lStream.on('error', reject);
|
|
3109
|
+
});
|
|
3110
|
+
let lessons = [];
|
|
3111
|
+
if (lessonKeys.length > 0) {
|
|
3112
|
+
const vals = await redis.mget(...lessonKeys);
|
|
3113
|
+
for (const raw of vals) {
|
|
3114
|
+
if (!raw)
|
|
3115
|
+
continue;
|
|
3116
|
+
try {
|
|
3117
|
+
lessons.push(JSON.parse(raw));
|
|
3118
|
+
}
|
|
3119
|
+
catch { /* skip */ }
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
lessons.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
|
|
3123
|
+
if (max_lessons && max_lessons > 0)
|
|
3124
|
+
lessons = lessons.slice(0, max_lessons);
|
|
3125
|
+
// Last session + crystal
|
|
3126
|
+
const lastSessionRaw = await redis.get('cachly:session:last');
|
|
3127
|
+
let lastSession = null;
|
|
3128
|
+
if (lastSessionRaw) {
|
|
3129
|
+
try {
|
|
3130
|
+
lastSession = JSON.parse(lastSessionRaw);
|
|
3131
|
+
}
|
|
3132
|
+
catch { /* ignore */ }
|
|
3133
|
+
}
|
|
3134
|
+
const crystalRaw = await redis.get('cachly:crystal');
|
|
3135
|
+
let crystal = null;
|
|
3136
|
+
if (crystalRaw) {
|
|
3137
|
+
try {
|
|
3138
|
+
crystal = JSON.parse(crystalRaw);
|
|
3139
|
+
}
|
|
3140
|
+
catch { /* ignore */ }
|
|
3141
|
+
}
|
|
3142
|
+
const ctxEntries = [];
|
|
3143
|
+
if (include_context) {
|
|
3144
|
+
const ctxKeys = [];
|
|
3145
|
+
const ctxStream = redis.scanStream({ match: 'cachly:ctx:*', count: 200 });
|
|
3146
|
+
await new Promise((resolve, reject) => {
|
|
3147
|
+
ctxStream.on('data', (batch) => {
|
|
3148
|
+
ctxKeys.push(...batch.filter((k) => !k.endsWith(':meta')));
|
|
3149
|
+
});
|
|
3150
|
+
ctxStream.on('end', resolve);
|
|
3151
|
+
ctxStream.on('error', reject);
|
|
3152
|
+
});
|
|
3153
|
+
if (ctxKeys.length > 0) {
|
|
3154
|
+
const ctxVals = await redis.mget(...ctxKeys);
|
|
3155
|
+
for (let i = 0; i < ctxKeys.length; i++) {
|
|
3156
|
+
const val = ctxVals[i];
|
|
3157
|
+
if (val)
|
|
3158
|
+
ctxEntries.push({ key: ctxKeys[i].replace('cachly:ctx:', ''), value: val });
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
const exportedAt = new Date().toISOString();
|
|
3163
|
+
const totalRecalls = lessons.reduce((s, l) => s + (l.recall_count ?? 0), 0);
|
|
3164
|
+
const savedHours = (totalRecalls * 15 / 60).toFixed(1);
|
|
3165
|
+
const md = [
|
|
3166
|
+
`# 🧠 cachly Brain Export`,
|
|
3167
|
+
``,
|
|
3168
|
+
`> Exported: ${exportedAt} `,
|
|
3169
|
+
`> Instance: \`${instance_id}\` `,
|
|
3170
|
+
`> Lessons: ${lessons.length} · Recalls: ${totalRecalls} · Time saved: ~${savedHours}h`,
|
|
3171
|
+
``,
|
|
3172
|
+
];
|
|
3173
|
+
if (crystal) {
|
|
3174
|
+
md.push(`## 💎 Memory Crystal`, ``, `> ${crystal.summary}`, ``, `_Generated: ${crystal.created_at}_`, ``);
|
|
3175
|
+
}
|
|
3176
|
+
if (lastSession) {
|
|
3177
|
+
md.push(`## 📅 Last Session`, ``, `**${lastSession.ts.slice(0, 10)}** — ${lastSession.summary}`, ``);
|
|
3178
|
+
}
|
|
3179
|
+
// Group lessons by outcome
|
|
3180
|
+
const successLessons = lessons.filter(l => l.outcome === 'success');
|
|
3181
|
+
const failureLessons = lessons.filter(l => l.outcome !== 'success');
|
|
3182
|
+
const formatLesson = (l) => {
|
|
3183
|
+
const sev = l.severity === 'critical' ? ' 🔴' : l.severity === 'major' ? ' 🟡' : '';
|
|
3184
|
+
const lines = [
|
|
3185
|
+
`### \`${l.topic}\`${sev}`,
|
|
3186
|
+
``,
|
|
3187
|
+
`**Outcome:** ${l.outcome} `,
|
|
3188
|
+
`**Recalls:** ${l.recall_count ?? 0}× `,
|
|
3189
|
+
`**Stored:** ${l.ts.slice(0, 10)}${l.author ? ` \n**Author:** ${l.author}` : ''}`,
|
|
3190
|
+
``,
|
|
3191
|
+
`**What worked:** `,
|
|
3192
|
+
l.what_worked,
|
|
3193
|
+
];
|
|
3194
|
+
if (l.what_failed) {
|
|
3195
|
+
lines.push(``, `**What failed:** `, l.what_failed);
|
|
3196
|
+
}
|
|
3197
|
+
if ((l.commands ?? []).length > 0) {
|
|
3198
|
+
lines.push(``, `**Commands:**`);
|
|
3199
|
+
for (const cmd of l.commands)
|
|
3200
|
+
lines.push(`\`\`\`\n${cmd}\n\`\`\``);
|
|
3201
|
+
}
|
|
3202
|
+
if ((l.file_paths ?? []).length > 0) {
|
|
3203
|
+
lines.push(``, `**Files:** ${l.file_paths.map(f => `\`${f}\``).join(', ')}`);
|
|
3204
|
+
}
|
|
3205
|
+
if ((l.tags ?? []).length > 0) {
|
|
3206
|
+
lines.push(``, `**Tags:** ${l.tags.map(t => `\`${t}\``).join(', ')}`);
|
|
3207
|
+
}
|
|
3208
|
+
lines.push(``);
|
|
3209
|
+
return lines.join('\n');
|
|
3210
|
+
};
|
|
3211
|
+
if (successLessons.length > 0) {
|
|
3212
|
+
md.push(`## ✅ Successful Solutions (${successLessons.length})`, ``);
|
|
3213
|
+
for (const l of successLessons)
|
|
3214
|
+
md.push(formatLesson(l));
|
|
3215
|
+
}
|
|
3216
|
+
if (failureLessons.length > 0) {
|
|
3217
|
+
md.push(`## ⚠️ Open / Partial Issues (${failureLessons.length})`, ``);
|
|
3218
|
+
for (const l of failureLessons)
|
|
3219
|
+
md.push(formatLesson(l));
|
|
3220
|
+
}
|
|
3221
|
+
if (ctxEntries.length > 0) {
|
|
3222
|
+
md.push(`## 📁 Context Entries (${ctxEntries.length})`, ``);
|
|
3223
|
+
for (const e of ctxEntries) {
|
|
3224
|
+
md.push(`### \`${e.key}\``, ``, e.value.slice(0, 500) + (e.value.length > 500 ? '\n\n_[truncated]_' : ''), ``);
|
|
3225
|
+
}
|
|
3226
|
+
}
|
|
3227
|
+
md.push(`---`, ``, `_Exported from [cachly AI Brain](https://cachly.dev) · Re-import with \`npx @cachly-dev/mcp-server@latest setup\`_`);
|
|
3228
|
+
return md.join('\n');
|
|
3229
|
+
}
|
|
3230
|
+
// ── invite_link ───────────────────────────────────────────────────────────
|
|
3231
|
+
case 'invite_link': {
|
|
3232
|
+
const { instance_id, label = '' } = args;
|
|
3233
|
+
// Generate a share token via API
|
|
3234
|
+
let shareToken = '';
|
|
3235
|
+
let inviteUrl = '';
|
|
3236
|
+
try {
|
|
3237
|
+
const res = await apiFetch(`/api/v1/instances/${instance_id}/invite`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label }) });
|
|
3238
|
+
shareToken = res.token ?? '';
|
|
3239
|
+
inviteUrl = res.url ?? '';
|
|
3240
|
+
}
|
|
3241
|
+
catch {
|
|
3242
|
+
// API doesn't support invite yet — generate a local share snippet instead
|
|
3243
|
+
}
|
|
3244
|
+
// Build the join command
|
|
3245
|
+
const joinCmd = shareToken
|
|
3246
|
+
? `npx @cachly-dev/mcp-server@latest join ${shareToken}`
|
|
3247
|
+
: `npx @cachly-dev/mcp-server@latest setup`;
|
|
3248
|
+
const lines = [
|
|
3249
|
+
`🔗 **Team Invite — ${label || instance_id.slice(0, 8) + '…'}**`,
|
|
3250
|
+
``,
|
|
3251
|
+
`Share this command with your teammate:`,
|
|
3252
|
+
``,
|
|
3253
|
+
`\`\`\`bash`,
|
|
3254
|
+
joinCmd,
|
|
3255
|
+
`\`\`\``,
|
|
3256
|
+
``,
|
|
3257
|
+
`**What happens when they run it:**`,
|
|
3258
|
+
` 1. Browser opens → they sign in (free, 30 seconds)`,
|
|
3259
|
+
` 2. Their Brain is auto-connected to this shared instance`,
|
|
3260
|
+
` 3. All ${label ? `"${label}" ` : ''}lessons and context are instantly available`,
|
|
3261
|
+
` 4. Editor configs written automatically — no copy-paste`,
|
|
3262
|
+
``,
|
|
3263
|
+
];
|
|
3264
|
+
if (inviteUrl) {
|
|
3265
|
+
lines.push(`**Or share the link directly:** ${inviteUrl}`, ``);
|
|
3266
|
+
}
|
|
3267
|
+
lines.push(`💡 Teammates can also run \`session_start\` immediately to see all your lessons.`, ` Use \`team_learn\` with an \`author\` param so everyone knows who learned what.`);
|
|
3268
|
+
return lines.join('\n');
|
|
3269
|
+
}
|
|
2955
3270
|
// ── brain_doctor ──────────────────────────────────────────────────────────
|
|
2956
3271
|
case 'brain_doctor': {
|
|
2957
3272
|
const { instance_id } = args;
|
|
@@ -2977,14 +3292,16 @@ async function handleTool(name, args) {
|
|
|
2977
3292
|
ctxStream.on('error', reject);
|
|
2978
3293
|
});
|
|
2979
3294
|
const lessons = [];
|
|
2980
|
-
|
|
2981
|
-
const
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
3295
|
+
if (lessonKeys.length > 0) {
|
|
3296
|
+
const vals = await redis.mget(...lessonKeys);
|
|
3297
|
+
for (const raw of vals) {
|
|
3298
|
+
if (!raw)
|
|
3299
|
+
continue;
|
|
3300
|
+
try {
|
|
3301
|
+
lessons.push(JSON.parse(raw));
|
|
3302
|
+
}
|
|
3303
|
+
catch { /* skip */ }
|
|
2986
3304
|
}
|
|
2987
|
-
catch { /* skip */ }
|
|
2988
3305
|
}
|
|
2989
3306
|
// Last session
|
|
2990
3307
|
const lastSessionRaw = await redis.get('cachly:session:last');
|
|
@@ -3893,6 +4210,164 @@ if (process.argv[2] === 'setup') {
|
|
|
3893
4210
|
console.log(` Your AI now has persistent memory across every session.\n`);
|
|
3894
4211
|
process.exit(0);
|
|
3895
4212
|
}
|
|
4213
|
+
// ── CLI: cachly join <token> (team invite — zero friction) ────────────────────
|
|
4214
|
+
// Usage: npx @cachly-dev/mcp-server join <token>
|
|
4215
|
+
//
|
|
4216
|
+
// Connects the user to a shared Brain instance via an invite link.
|
|
4217
|
+
// Flow: resolve token → show instance info → Device Code auth → write configs.
|
|
4218
|
+
if (process.argv[2] === 'join') {
|
|
4219
|
+
const { writeFile, mkdir } = await import('node:fs/promises');
|
|
4220
|
+
const { existsSync } = await import('node:fs');
|
|
4221
|
+
const { resolve, dirname } = await import('node:path');
|
|
4222
|
+
const inviteToken = process.argv[3] ?? '';
|
|
4223
|
+
if (!inviteToken) {
|
|
4224
|
+
console.error('\n❌ Usage: npx @cachly-dev/mcp-server@latest join <token>\n');
|
|
4225
|
+
console.error(' Get a token from a teammate: invite_link() in their AI assistant.\n');
|
|
4226
|
+
process.exit(1);
|
|
4227
|
+
}
|
|
4228
|
+
console.log('\n🧠 cachly AI Brain — Team Join');
|
|
4229
|
+
console.log('──────────────────────────────────\n');
|
|
4230
|
+
// Step 1: Resolve the invite token (public, no auth)
|
|
4231
|
+
process.stdout.write('⏳ Resolving invite...');
|
|
4232
|
+
let instanceId = '', instanceName = '', instanceTier = '';
|
|
4233
|
+
try {
|
|
4234
|
+
const infoRes = await fetch(`${API_URL}/api/invite/${inviteToken}`, {
|
|
4235
|
+
signal: AbortSignal.timeout(10000),
|
|
4236
|
+
});
|
|
4237
|
+
if (!infoRes.ok) {
|
|
4238
|
+
console.error(`\n\n❌ Invite not found or expired (HTTP ${infoRes.status}).`);
|
|
4239
|
+
console.error(' Ask your teammate to generate a new one with: invite_link()\n');
|
|
4240
|
+
process.exit(1);
|
|
4241
|
+
}
|
|
4242
|
+
const info = await infoRes.json();
|
|
4243
|
+
instanceId = info.instance_id;
|
|
4244
|
+
instanceName = info.instance_name;
|
|
4245
|
+
instanceTier = info.tier ?? 'free';
|
|
4246
|
+
console.log(` ✓\n`);
|
|
4247
|
+
const label = info.label ? ` ("${info.label}")` : '';
|
|
4248
|
+
console.log(` Brain instance: \x1b[1m${instanceName}\x1b[0m${label} [${instanceTier}]`);
|
|
4249
|
+
console.log(` Instance ID: ${instanceId}\n`);
|
|
4250
|
+
}
|
|
4251
|
+
catch (e) {
|
|
4252
|
+
console.error(`\n\n❌ Could not reach API: ${e.message}\n`);
|
|
4253
|
+
process.exit(1);
|
|
4254
|
+
}
|
|
4255
|
+
// Step 2: Authenticate via Device Code Flow
|
|
4256
|
+
const AUTH_BASE = 'https://auth.cachly.dev/realms/cachly/protocol/openid-connect';
|
|
4257
|
+
const CLIENT_ID = 'cachly-cli';
|
|
4258
|
+
console.log('Step 1: Sign in to cachly (your own account — free, no credit card)\n');
|
|
4259
|
+
let token = process.env.CACHLY_JWT ?? '';
|
|
4260
|
+
if (token) {
|
|
4261
|
+
console.log('✓ Using token from CACHLY_JWT env var\n');
|
|
4262
|
+
}
|
|
4263
|
+
else {
|
|
4264
|
+
let deviceCode = '', userCode = '', verifyUri = '', pollInterval = 5000;
|
|
4265
|
+
try {
|
|
4266
|
+
const deviceRes = await fetch(`${AUTH_BASE}/auth/device`, {
|
|
4267
|
+
method: 'POST',
|
|
4268
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
4269
|
+
body: `client_id=${CLIENT_ID}&scope=openid`,
|
|
4270
|
+
signal: AbortSignal.timeout(10000),
|
|
4271
|
+
});
|
|
4272
|
+
if (!deviceRes.ok)
|
|
4273
|
+
throw new Error(`HTTP ${deviceRes.status}`);
|
|
4274
|
+
const data = await deviceRes.json();
|
|
4275
|
+
deviceCode = data.device_code;
|
|
4276
|
+
userCode = data.user_code;
|
|
4277
|
+
verifyUri = data.verification_uri_complete;
|
|
4278
|
+
pollInterval = (data.interval ?? 5) * 1000;
|
|
4279
|
+
}
|
|
4280
|
+
catch (e) {
|
|
4281
|
+
console.error(`\n❌ Auth service unreachable: ${e.message}`);
|
|
4282
|
+
process.exit(1);
|
|
4283
|
+
}
|
|
4284
|
+
console.log(` Code: \x1b[1;33m${userCode}\x1b[0m`);
|
|
4285
|
+
console.log(` URL: ${verifyUri}\n`);
|
|
4286
|
+
try {
|
|
4287
|
+
const { execSync } = await import('node:child_process');
|
|
4288
|
+
const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
4289
|
+
execSync(`${openCmd} "${verifyUri}"`, { stdio: 'ignore' });
|
|
4290
|
+
console.log(' ✓ Browser opened — confirm the code above to continue...\n');
|
|
4291
|
+
}
|
|
4292
|
+
catch {
|
|
4293
|
+
console.log(' 👉 Open the URL above in your browser and confirm the code.\n');
|
|
4294
|
+
}
|
|
4295
|
+
process.stdout.write(' Waiting for authorization');
|
|
4296
|
+
const deadline = Date.now() + 10 * 60 * 1000;
|
|
4297
|
+
while (Date.now() < deadline) {
|
|
4298
|
+
await new Promise(r => setTimeout(r, pollInterval));
|
|
4299
|
+
process.stdout.write('.');
|
|
4300
|
+
try {
|
|
4301
|
+
const td = await (await fetch(`${AUTH_BASE}/token`, {
|
|
4302
|
+
method: 'POST',
|
|
4303
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
4304
|
+
body: `client_id=${CLIENT_ID}&grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=${deviceCode}`,
|
|
4305
|
+
})).json();
|
|
4306
|
+
if (td.access_token) {
|
|
4307
|
+
token = td.access_token;
|
|
4308
|
+
console.log(' \x1b[32m✓ Authorized!\x1b[0m\n');
|
|
4309
|
+
break;
|
|
4310
|
+
}
|
|
4311
|
+
if (td.error === 'slow_down')
|
|
4312
|
+
pollInterval = Math.min(pollInterval + 2000, 15000);
|
|
4313
|
+
else if (td.error && td.error !== 'authorization_pending') {
|
|
4314
|
+
console.error(`\n\n❌ Auth error: ${td.error}\n`);
|
|
4315
|
+
process.exit(1);
|
|
4316
|
+
}
|
|
4317
|
+
}
|
|
4318
|
+
catch { /* hiccup — keep polling */ }
|
|
4319
|
+
}
|
|
4320
|
+
if (!token) {
|
|
4321
|
+
console.error('\n\n❌ Timed out. Run the command again.\n');
|
|
4322
|
+
process.exit(1);
|
|
4323
|
+
}
|
|
4324
|
+
// Exchange Keycloak JWT → long-lived cky_live_ API key
|
|
4325
|
+
if (token.startsWith('eyJ')) {
|
|
4326
|
+
process.stdout.write('⏳ Generating your API key...');
|
|
4327
|
+
try {
|
|
4328
|
+
const keyBody = await (await fetch(`${API_URL}/api/v1/api-keys`, {
|
|
4329
|
+
method: 'POST',
|
|
4330
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
4331
|
+
body: JSON.stringify({ name: 'cachly-mcp-join', scope: 'read_write' }),
|
|
4332
|
+
})).json();
|
|
4333
|
+
if (keyBody.key) {
|
|
4334
|
+
token = keyBody.key;
|
|
4335
|
+
console.log(' ✓\n');
|
|
4336
|
+
}
|
|
4337
|
+
else
|
|
4338
|
+
throw new Error('no key in response');
|
|
4339
|
+
}
|
|
4340
|
+
catch {
|
|
4341
|
+
console.log(' (skipped — JWT used directly)\n');
|
|
4342
|
+
}
|
|
4343
|
+
}
|
|
4344
|
+
}
|
|
4345
|
+
// Step 3: Detect editors and write configs
|
|
4346
|
+
const cwd = process.cwd();
|
|
4347
|
+
const detected = ['claude'];
|
|
4348
|
+
if (existsSync(resolve(cwd, '.cursor')))
|
|
4349
|
+
detected.push('cursor');
|
|
4350
|
+
if (existsSync(resolve(cwd, '.windsurf')))
|
|
4351
|
+
detected.push('windsurf');
|
|
4352
|
+
if (existsSync(resolve(cwd, '.vscode')))
|
|
4353
|
+
detected.push('copilot');
|
|
4354
|
+
if (existsSync(resolve(cwd, '.continue')))
|
|
4355
|
+
detected.push('continue');
|
|
4356
|
+
console.log(`Step 2: Writing configs for: ${detected.join(', ')}\n`);
|
|
4357
|
+
for (const editor of detected) {
|
|
4358
|
+
const configFile = EDITOR_FILES[editor] ?? '.mcp.json';
|
|
4359
|
+
const configPath = resolve(cwd, configFile);
|
|
4360
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
4361
|
+
await writeFile(configPath, buildMcpConfig(token, instanceId, editor), 'utf-8');
|
|
4362
|
+
console.log(`✅ Written: ${configFile}`);
|
|
4363
|
+
}
|
|
4364
|
+
const mdResult = await writeClaudeMd(cwd, instanceId);
|
|
4365
|
+
const mdLabel = mdResult === 'updated' ? '✅ Updated' : mdResult === 'appended' ? '✅ Appended to' : '✅ Written';
|
|
4366
|
+
console.log(`${mdLabel}: CLAUDE.md`);
|
|
4367
|
+
console.log(`\n🧠 Joined! You're now connected to "${instanceName}".`);
|
|
4368
|
+
console.log(` Restart your editor — all ${instanceName} lessons are now available via session_start.\n`);
|
|
4369
|
+
process.exit(0);
|
|
4370
|
+
}
|
|
3896
4371
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
3897
4372
|
const transport = new StdioServerTransport();
|
|
3898
4373
|
await server.connect(transport);
|