@viren/claude-code-dashboard 0.0.6 → 0.0.8

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/src/mcp.mjs CHANGED
@@ -1,6 +1,11 @@
1
- import { existsSync, readdirSync, readFileSync, statSync } from "fs";
1
+ import { existsSync, readdirSync, readFileSync, writeFileSync, statSync } from "fs";
2
2
  import { join } from "path";
3
- import { MAX_SESSION_SCAN } from "./constants.mjs";
3
+ import {
4
+ MAX_SESSION_SCAN,
5
+ MCP_REGISTRY_URL,
6
+ MCP_REGISTRY_TTL_MS,
7
+ CLAUDE_DIR,
8
+ } from "./constants.mjs";
4
9
 
5
10
  export function parseUserMcpConfig(content) {
6
11
  try {
@@ -175,3 +180,93 @@ export function classifyHistoricalServers(
175
180
  former.sort((a, b) => a.name.localeCompare(b.name));
176
181
  return { recent, former };
177
182
  }
183
+
184
+ /**
185
+ * Pure normalizer: extract claude-code compatible servers from raw registry API response.
186
+ * Returns [] on any malformed input.
187
+ */
188
+ export function normalizeRegistryResponse(raw) {
189
+ try {
190
+ if (!raw || !Array.isArray(raw.servers)) return [];
191
+ return raw.servers
192
+ .map((entry) => {
193
+ // The registry API nests data: entry.server has the MCP spec fields,
194
+ // entry._meta["com.anthropic.api/mcp-registry"] has Anthropic's curated metadata.
195
+ // Also support flat shape (used in tests and demo data).
196
+ const anth = entry?._meta?.["com.anthropic.api/mcp-registry"] || {};
197
+ const srv = entry?.server || entry || {};
198
+ const name = anth.displayName || entry.name || srv.title || "";
199
+ return {
200
+ name,
201
+ slug:
202
+ anth.slug ||
203
+ entry.slug ||
204
+ name
205
+ .toLowerCase()
206
+ .replace(/[^a-z0-9]+/g, "-")
207
+ .replace(/^-|-$/g, ""),
208
+ description: anth.oneLiner || entry.description || srv.description || "",
209
+ url: anth.url || entry.url || "",
210
+ installCommand: anth.claudeCodeCopyText || entry.installCommand || "",
211
+ worksWith: anth.worksWith || entry.worksWith || [],
212
+ tools: anth.toolNames || entry.tools || [],
213
+ };
214
+ })
215
+ .filter((s) => Array.isArray(s.worksWith) && s.worksWith.includes("claude-code"));
216
+ } catch {
217
+ return [];
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Fetch MCP registry servers with 24h file cache.
223
+ * Falls back to stale cache on network failure, returns [] on total failure.
224
+ */
225
+ export async function fetchRegistryServers() {
226
+ const cachePath = join(CLAUDE_DIR, "mcp-registry-cache.json");
227
+
228
+ // Try fresh cache
229
+ try {
230
+ const cached = JSON.parse(readFileSync(cachePath, "utf8"));
231
+ if (cached._ts && Date.now() - cached._ts < MCP_REGISTRY_TTL_MS) {
232
+ return normalizeRegistryResponse(cached.data);
233
+ }
234
+ } catch {
235
+ /* no cache or unreadable */
236
+ }
237
+
238
+ // Fetch from registry
239
+ try {
240
+ const controller = new AbortController();
241
+ const timeout = setTimeout(() => controller.abort(), 8000);
242
+ const res = await fetch(MCP_REGISTRY_URL, { signal: controller.signal });
243
+ clearTimeout(timeout);
244
+
245
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
246
+ const data = await res.json();
247
+ const normalized = normalizeRegistryResponse(data);
248
+
249
+ // Only cache valid responses to avoid 24h blackout on malformed data
250
+ if (Array.isArray(data?.servers) && data.servers.length > 0) {
251
+ try {
252
+ writeFileSync(cachePath, JSON.stringify({ _ts: Date.now(), data }));
253
+ } catch {
254
+ /* non-critical */
255
+ }
256
+ }
257
+
258
+ return normalized;
259
+ } catch {
260
+ /* network failure — try stale cache */
261
+ }
262
+
263
+ // Stale cache fallback (ignore TTL)
264
+ try {
265
+ const cached = JSON.parse(readFileSync(cachePath, "utf8"));
266
+ return normalizeRegistryResponse(cached.data);
267
+ } catch {
268
+ /* total failure */
269
+ }
270
+
271
+ return [];
272
+ }
@@ -0,0 +1,596 @@
1
+ /**
2
+ * Pure processing pipeline — transforms raw scan data into the shape
3
+ * that generateDashboardHtml() expects.
4
+ *
5
+ * NO filesystem I/O, NO git commands, NO process.exit.
6
+ * All data comes in via the `raw` parameter.
7
+ */
8
+
9
+ import { SIMILARITY_THRESHOLD, MCP_STACK_HINTS } from "./constants.mjs";
10
+ import { relativeTime, freshnessClass } from "./freshness.mjs";
11
+ import {
12
+ computeHealthScore,
13
+ classifyDrift,
14
+ detectConfigPattern,
15
+ findExemplar,
16
+ generateSuggestions,
17
+ computeConfigSimilarity,
18
+ matchSkillsToRepo,
19
+ } from "./analysis.mjs";
20
+ import { findPromotionCandidates, classifyHistoricalServers } from "./mcp.mjs";
21
+ import { aggregateSessionMeta } from "./usage.mjs";
22
+
23
+ /**
24
+ * Build the complete dashboard data object from raw scan inputs.
25
+ *
26
+ * @param {object} raw - All I/O data collected before this function is called.
27
+ * @returns {object} Data object ready for generateDashboardHtml().
28
+ */
29
+ export function buildDashboardData(raw) {
30
+ // ── 1. Repo Classification ──────────────────────────────────────────────
31
+
32
+ const configured = [];
33
+ const unconfigured = [];
34
+ const seenNames = new Map();
35
+
36
+ for (const repo of raw.repos) {
37
+ // Collision-safe display key
38
+ const count = (seenNames.get(repo.name) || 0) + 1;
39
+ seenNames.set(repo.name, count);
40
+ const key = count > 1 ? `${repo.name}__${count}` : repo.name;
41
+
42
+ const entry = {
43
+ key,
44
+ name: repo.name,
45
+ path: repo.path,
46
+ shortPath: repo.shortPath,
47
+ commands: repo.commands || [],
48
+ rules: repo.rules || [],
49
+ desc: repo.desc || [],
50
+ sections: repo.sections || [],
51
+ freshness: repo.freshness || 0,
52
+ freshnessText: "",
53
+ freshnessClass: "stale",
54
+ techStack: repo.techStack || [],
55
+ };
56
+
57
+ const hasConfig = entry.commands.length > 0 || entry.rules.length > 0 || repo.agentsFile;
58
+
59
+ if (hasConfig) {
60
+ entry.freshnessText = relativeTime(entry.freshness);
61
+ entry.freshnessClass = freshnessClass(entry.freshness);
62
+
63
+ // Health score
64
+ const health = computeHealthScore({
65
+ hasAgentsFile: !!repo.agentsFile,
66
+ desc: entry.desc,
67
+ commandCount: entry.commands.length,
68
+ ruleCount: entry.rules.length,
69
+ sectionCount: entry.sections.length,
70
+ freshnessClass: entry.freshnessClass,
71
+ });
72
+ entry.healthScore = health.score;
73
+ entry.healthReasons = health.reasons;
74
+ entry.hasAgentsFile = !!repo.agentsFile;
75
+ entry.configPattern = detectConfigPattern(entry);
76
+
77
+ // Drift classification from pre-computed gitRevCount (no git I/O)
78
+ entry.drift = classifyDrift(repo.gitRevCount);
79
+
80
+ configured.push(entry);
81
+ } else {
82
+ unconfigured.push(entry);
83
+ }
84
+ }
85
+
86
+ // Sort configured by richness (most config first)
87
+ configured.sort((a, b) => {
88
+ const score = (r) =>
89
+ r.commands.length * 3 + r.rules.length * 2 + r.sections.length + (r.desc.length > 0 ? 1 : 0);
90
+ return score(b) - score(a);
91
+ });
92
+
93
+ unconfigured.sort((a, b) => a.name.localeCompare(b.name));
94
+
95
+ // ── 2. Cross-Repo Analysis ────────────────────────────────────────────
96
+
97
+ // Suggestions for unconfigured repos
98
+ for (const repo of unconfigured) {
99
+ const exemplar = findExemplar(repo.techStack, configured);
100
+ if (exemplar) {
101
+ repo.suggestions = generateSuggestions(exemplar);
102
+ repo.exemplarName = exemplar.name;
103
+ } else {
104
+ repo.suggestions = [];
105
+ repo.exemplarName = "";
106
+ }
107
+ }
108
+
109
+ // Similar repos + matched skills for configured repos
110
+ for (const repo of configured) {
111
+ const similar = configured
112
+ .filter((r) => r !== repo)
113
+ .map((r) => ({
114
+ name: r.name,
115
+ similarity: computeConfigSimilarity(repo, r),
116
+ }))
117
+ .filter((r) => r.similarity >= SIMILARITY_THRESHOLD)
118
+ .sort((a, b) => b.similarity - a.similarity)
119
+ .slice(0, 2);
120
+ repo.similarRepos = similar;
121
+ repo.matchedSkills = matchSkillsToRepo(repo, raw.globalSkills);
122
+ }
123
+
124
+ // Consolidation groups
125
+ const consolidationGroups = [];
126
+ const byStack = {};
127
+ for (const repo of configured) {
128
+ for (const s of repo.techStack || []) {
129
+ if (!byStack[s]) byStack[s] = [];
130
+ byStack[s].push(repo);
131
+ }
132
+ }
133
+ for (const [stack, repos] of Object.entries(byStack)) {
134
+ if (repos.length >= 3) {
135
+ let pairCount = 0;
136
+ let simSum = 0;
137
+ for (let i = 0; i < repos.length; i++) {
138
+ for (let j = i + 1; j < repos.length; j++) {
139
+ simSum += computeConfigSimilarity(repos[i], repos[j]);
140
+ pairCount++;
141
+ }
142
+ }
143
+ const avgSimilarity = pairCount > 0 ? Math.round(simSum / pairCount) : 0;
144
+ if (avgSimilarity >= 30) {
145
+ consolidationGroups.push({
146
+ stack,
147
+ repos: repos.map((r) => r.name),
148
+ avgSimilarity,
149
+ suggestion: `${repos.length} ${stack} repos with ${avgSimilarity}% avg similarity — consider shared global rules`,
150
+ });
151
+ }
152
+ }
153
+ }
154
+
155
+ // ── 3. MCP Aggregation ────────────────────────────────────────────────
156
+
157
+ const allMcpServers = [...(raw.userMcpServers || [])];
158
+
159
+ // Add project MCP servers and attach to matching repos
160
+ for (const [repoPath, servers] of Object.entries(raw.projectMcpByRepo || {})) {
161
+ allMcpServers.push(...servers);
162
+ const repo =
163
+ configured.find((r) => r.path === repoPath) || unconfigured.find((r) => r.path === repoPath);
164
+ if (repo) repo.mcpServers = servers;
165
+ }
166
+
167
+ const mcpPromotions = findPromotionCandidates(allMcpServers);
168
+
169
+ // Build disabled-by-server counts
170
+ const disabledByServer = {};
171
+ for (const [, names] of Object.entries(raw.disabledMcpByRepo || {})) {
172
+ for (const name of names) {
173
+ disabledByServer[name] = (disabledByServer[name] || 0) + 1;
174
+ }
175
+ }
176
+
177
+ // Build mcpByName map
178
+ const mcpByName = {};
179
+ for (const s of allMcpServers) {
180
+ if (!mcpByName[s.name])
181
+ mcpByName[s.name] = {
182
+ name: s.name,
183
+ type: s.type,
184
+ projects: [],
185
+ userLevel: false,
186
+ disabledIn: 0,
187
+ };
188
+ if (s.scope === "user") mcpByName[s.name].userLevel = true;
189
+ if (s.scope === "project") mcpByName[s.name].projects.push(s.source);
190
+ }
191
+ for (const entry of Object.values(mcpByName)) {
192
+ entry.disabledIn = disabledByServer[entry.name] || 0;
193
+ }
194
+
195
+ // Historical MCP servers
196
+ const currentMcpNames = new Set(allMcpServers.map((s) => s.name));
197
+ const { recent: recentMcpServers, former: formerMcpServers } = classifyHistoricalServers(
198
+ raw.historicalMcpMap || new Map(),
199
+ currentMcpNames,
200
+ );
201
+
202
+ // Historical project paths are already normalized by the caller (collectRawInputs
203
+ // applies shortPath on the I/O side, demo data uses short paths directly).
204
+
205
+ // Merge recently-seen servers into mcpByName
206
+ for (const server of recentMcpServers) {
207
+ if (!mcpByName[server.name]) {
208
+ mcpByName[server.name] = {
209
+ name: server.name,
210
+ type: "unknown",
211
+ projects: server.projects,
212
+ userLevel: false,
213
+ disabledIn: disabledByServer[server.name] || 0,
214
+ recentlyActive: true,
215
+ };
216
+ }
217
+ }
218
+
219
+ const mcpSummary = Object.values(mcpByName).sort((a, b) => {
220
+ if (a.userLevel !== b.userLevel) return a.userLevel ? -1 : 1;
221
+ return a.name.localeCompare(b.name);
222
+ });
223
+ const mcpCount = mcpSummary.length;
224
+
225
+ // ── 3b. MCP Registry — Available & Recommended ────────────────────────
226
+
227
+ const registryServers = raw.registryServers || [];
228
+ const registryTotal = registryServers.length;
229
+
230
+ // Build a set of installed server identifiers (lowercase names)
231
+ // Use mcpSummary which includes user, project, AND recently-active servers
232
+ const installedIds = new Set();
233
+ for (const s of mcpSummary) {
234
+ installedIds.add(s.name.toLowerCase());
235
+ }
236
+
237
+ // Filter out already-installed servers
238
+ const notInstalled = registryServers.filter(
239
+ (s) =>
240
+ !installedIds.has((s.slug || "").toLowerCase()) &&
241
+ !installedIds.has((s.name || "").toLowerCase()),
242
+ );
243
+
244
+ // Collect tech stacks and description text from all repos
245
+ const allRepos = [...configured, ...unconfigured];
246
+ const stackCounts = {}; // key -> count of repos with that stack
247
+ for (const repo of allRepos) {
248
+ for (const stack of repo.techStack || []) {
249
+ const k = stack.toLowerCase();
250
+ stackCounts[k] = (stackCounts[k] || 0) + 1;
251
+ }
252
+ }
253
+
254
+ // Join all descriptions into a single lowercased string for substring matching
255
+ // (supports multi-word keys like "hugging face")
256
+ const allDescText = allRepos
257
+ .flatMap((r) => r.desc || [])
258
+ .join(" ")
259
+ .toLowerCase();
260
+
261
+ // Match hints against stacks and descriptions
262
+ const recommendedSlugs = new Map(); // slug -> { reasons: [], matchCount: 0 }
263
+ for (const [key, slugs] of Object.entries(MCP_STACK_HINTS)) {
264
+ const stackCount = stackCounts[key] || 0;
265
+ const inDesc = allDescText.includes(key);
266
+
267
+ if (stackCount > 0 || inDesc) {
268
+ for (const slug of slugs) {
269
+ if (!recommendedSlugs.has(slug)) {
270
+ recommendedSlugs.set(slug, { reasons: [], matchCount: 0 });
271
+ }
272
+ const entry = recommendedSlugs.get(slug);
273
+ if (stackCount > 0) {
274
+ entry.reasons.push(`${stackCount} ${key} repo${stackCount > 1 ? "s" : ""} detected`);
275
+ entry.matchCount += stackCount;
276
+ }
277
+ if (inDesc) {
278
+ entry.reasons.push("mentioned in repo descriptions");
279
+ entry.matchCount += 1;
280
+ }
281
+ }
282
+ }
283
+ }
284
+
285
+ // Build recommended list from not-installed servers that match hints
286
+ const recommendedMcpServers = [];
287
+ const recommendedSlugSet = new Set();
288
+ for (const server of notInstalled) {
289
+ const slug = (server.slug || "").toLowerCase();
290
+ if (recommendedSlugs.has(slug)) {
291
+ const { reasons, matchCount } = recommendedSlugs.get(slug);
292
+ recommendedMcpServers.push({ ...server, reasons, matchCount });
293
+ recommendedSlugSet.add(slug);
294
+ }
295
+ }
296
+
297
+ // Sort by relevance (more match signals first)
298
+ recommendedMcpServers.sort((a, b) => b.matchCount - a.matchCount || a.name.localeCompare(b.name));
299
+
300
+ // Available = not-installed minus recommended
301
+ const availableMcpServers = notInstalled.filter(
302
+ (s) => !recommendedSlugSet.has((s.slug || "").toLowerCase()),
303
+ );
304
+
305
+ // ── 4. Usage Analytics ────────────────────────────────────────────────
306
+
307
+ const usageAnalytics = aggregateSessionMeta(raw.sessionMetaFiles || []);
308
+
309
+ // ── 5. Insights Report Parsing ────────────────────────────────────────
310
+
311
+ let insightsReport = null;
312
+ if (raw.insightsReportHtml) {
313
+ try {
314
+ const reportHtml = raw.insightsReportHtml;
315
+
316
+ // Extract subtitle — reformat ISO dates to readable format
317
+ const subtitleMatch = reportHtml.match(/<p class="subtitle">([^<]+)<\/p>/);
318
+ let subtitle = subtitleMatch ? subtitleMatch[1] : null;
319
+ if (subtitle) {
320
+ subtitle = subtitle.replace(/(\d{4})-(\d{2})-(\d{2})/g, (_, y, m2, d) => {
321
+ const dt = new Date(`${y}-${m2}-${d}T00:00:00Z`);
322
+ return dt.toLocaleDateString("en-US", {
323
+ month: "short",
324
+ day: "numeric",
325
+ year: "numeric",
326
+ timeZone: "UTC",
327
+ });
328
+ });
329
+ }
330
+
331
+ // Extract glance sections
332
+ const glanceSections = [];
333
+ const glanceRe =
334
+ /<div class="glance-section"><strong>([^<]+)<\/strong>\s*([\s\S]*?)<a[^>]*class="see-more"/g;
335
+ let m;
336
+ while ((m = glanceRe.exec(reportHtml)) !== null) {
337
+ const text = m[2].replace(/<[^>]+>/g, "").trim();
338
+ glanceSections.push({ label: m[1].replace(/:$/, ""), text });
339
+ }
340
+
341
+ // Extract stats
342
+ const statsRe =
343
+ /<div class="stat-value">([^<]+)<\/div><div class="stat-label">([^<]+)<\/div>/g;
344
+ const reportStats = [];
345
+ while ((m = statsRe.exec(reportHtml)) !== null) {
346
+ const value = m[1];
347
+ const label = m[2];
348
+ const isDiff = /^[+-]/.test(value) && value.includes("/");
349
+ reportStats.push({ value, label, isDiff });
350
+ }
351
+
352
+ // Extract friction categories
353
+ const frictionRe =
354
+ /<div class="friction-title">([^<]+)<\/div>\s*<div class="friction-desc">([^<]+)<\/div>/g;
355
+ const frictionPoints = [];
356
+ while ((m = frictionRe.exec(reportHtml)) !== null) {
357
+ frictionPoints.push({ title: m[1], desc: m[2] });
358
+ }
359
+
360
+ if (glanceSections.length > 0 || reportStats.length > 0) {
361
+ insightsReport = {
362
+ subtitle,
363
+ glance: glanceSections,
364
+ stats: reportStats,
365
+ friction: frictionPoints.slice(0, 3),
366
+ filePath: raw.insightsReportPath || null,
367
+ };
368
+ }
369
+ } catch {
370
+ // skip if parsing fails
371
+ }
372
+ }
373
+
374
+ // ── 6. Stats Supplementation ──────────────────────────────────────────
375
+
376
+ // Make a copy so we don't mutate raw.statsCache
377
+ const statsCache = structuredClone(raw.statsCache || {});
378
+
379
+ // Supplement dailyActivity with session-meta data
380
+ const sessionMetaFiles = raw.sessionMetaFiles || [];
381
+ if (sessionMetaFiles.length > 0) {
382
+ const existingDates = new Set((statsCache.dailyActivity || []).map((d) => d.date));
383
+ const sessionDayCounts = {};
384
+ for (const s of sessionMetaFiles) {
385
+ const date = (s.start_time || "").slice(0, 10);
386
+ if (!date || existingDates.has(date)) continue;
387
+ sessionDayCounts[date] =
388
+ (sessionDayCounts[date] || 0) +
389
+ (s.user_message_count || 0) +
390
+ (s.assistant_message_count || 0);
391
+ }
392
+ const supplemental = Object.entries(sessionDayCounts).map(([date, messageCount]) => ({
393
+ date,
394
+ messageCount,
395
+ }));
396
+ if (supplemental.length > 0) {
397
+ statsCache.dailyActivity = [...(statsCache.dailyActivity || []), ...supplemental].sort(
398
+ (a, b) => a.date.localeCompare(b.date),
399
+ );
400
+ }
401
+ }
402
+
403
+ // Supplement dailyActivity with ccusage data
404
+ const ccusageData = raw.ccusageData;
405
+ if (ccusageData && ccusageData.daily) {
406
+ const existingDates = new Set((statsCache.dailyActivity || []).map((d) => d.date));
407
+ const ccusageSupplemental = ccusageData.daily
408
+ .filter((d) => d.date && !existingDates.has(d.date) && d.totalTokens > 0)
409
+ .map((d) => ({
410
+ date: d.date,
411
+ messageCount: Math.max(1, Math.round(d.totalTokens / 10000)),
412
+ }));
413
+ if (ccusageSupplemental.length > 0) {
414
+ statsCache.dailyActivity = [...(statsCache.dailyActivity || []), ...ccusageSupplemental].sort(
415
+ (a, b) => a.date.localeCompare(b.date),
416
+ );
417
+ }
418
+ }
419
+
420
+ // ── 7. Summary Stats ──────────────────────────────────────────────────
421
+
422
+ const totalRepos = raw.repos.length;
423
+ const configuredCount = configured.length;
424
+ const unconfiguredCount = unconfigured.length;
425
+ const coveragePct = totalRepos > 0 ? Math.round((configuredCount / totalRepos) * 100) : 0;
426
+ const totalRepoCmds = configured.reduce((sum, r) => sum + r.commands.length, 0);
427
+ const avgHealth =
428
+ configured.length > 0
429
+ ? Math.round(configured.reduce((sum, r) => sum + (r.healthScore || 0), 0) / configured.length)
430
+ : 0;
431
+ const driftCount = configured.filter(
432
+ (r) => r.drift && (r.drift.level === "medium" || r.drift.level === "high"),
433
+ ).length;
434
+
435
+ // ── 8. Insight Generation ─────────────────────────────────────────────
436
+
437
+ const insights = [];
438
+
439
+ // Drift alerts
440
+ const highDriftRepos = configured.filter((r) => r.drift?.level === "high");
441
+ if (highDriftRepos.length > 0) {
442
+ insights.push({
443
+ type: "warning",
444
+ title: `${highDriftRepos.length} repo${highDriftRepos.length > 1 ? "s have" : " has"} high config drift`,
445
+ detail: highDriftRepos
446
+ .map((r) => `${r.name} (${r.drift.commitsSince} commits since config update)`)
447
+ .join(", "),
448
+ action: "Review and update CLAUDE.md in these repos",
449
+ });
450
+ }
451
+
452
+ // Coverage
453
+ if (unconfigured.length > 0 && totalRepos > 0) {
454
+ const pct = Math.round((unconfigured.length / totalRepos) * 100);
455
+ if (pct >= 40) {
456
+ const withStack = unconfigured.filter((r) => r.techStack?.length > 0).slice(0, 3);
457
+ insights.push({
458
+ type: "info",
459
+ title: `${unconfigured.length} repos unconfigured (${pct}%)`,
460
+ detail: withStack.length
461
+ ? `Top candidates: ${withStack.map((r) => `${r.name} (${r.techStack.join(", ")})`).join(", ")}`
462
+ : "",
463
+ action: "Run claude-code-dashboard init --template <stack> in these repos",
464
+ });
465
+ }
466
+ }
467
+
468
+ // MCP promotions
469
+ if (mcpPromotions.length > 0) {
470
+ insights.push({
471
+ type: "promote",
472
+ title: `${mcpPromotions.length} MCP server${mcpPromotions.length > 1 ? "s" : ""} could be promoted to global`,
473
+ detail: mcpPromotions.map((p) => `${p.name} (in ${p.projects.length} projects)`).join(", "),
474
+ action: "Add to ~/.claude/mcp_config.json for all projects",
475
+ });
476
+ }
477
+
478
+ // Redundant project-scope MCP configs
479
+ const redundantMcp = Object.values(mcpByName).filter((s) => s.userLevel && s.projects.length > 0);
480
+ if (redundantMcp.length > 0) {
481
+ insights.push({
482
+ type: "tip",
483
+ title: `${redundantMcp.length} MCP server${redundantMcp.length > 1 ? "s are" : " is"} global but also in project .mcp.json`,
484
+ detail: redundantMcp.map((s) => `${s.name} (${s.projects.join(", ")})`).join("; "),
485
+ action: "Remove from project .mcp.json — global config already covers all projects",
486
+ });
487
+ }
488
+
489
+ // MCP recommendations
490
+ if (recommendedMcpServers.length > 0) {
491
+ insights.push({
492
+ type: "tip",
493
+ title: `${recommendedMcpServers.length} MCP server${recommendedMcpServers.length > 1 ? "s" : ""} recommended for your repos`,
494
+ detail: recommendedMcpServers
495
+ .slice(0, 3)
496
+ .map((s) => `${s.name} (${s.reasons.join(", ")})`)
497
+ .join(", "),
498
+ action: "Check the Skills & MCP tab for install commands",
499
+ });
500
+ }
501
+
502
+ // Skill sharing opportunities
503
+ const skillMatchCounts = {};
504
+ for (const r of configured) {
505
+ for (const sk of r.matchedSkills || []) {
506
+ const skName = typeof sk === "string" ? sk : sk.name;
507
+ if (!skillMatchCounts[skName]) skillMatchCounts[skName] = [];
508
+ skillMatchCounts[skName].push(r.name);
509
+ }
510
+ }
511
+ const widelyRelevant = Object.entries(skillMatchCounts)
512
+ .filter(([, repos]) => repos.length >= 3)
513
+ .sort((a, b) => b[1].length - a[1].length);
514
+ if (widelyRelevant.length > 0) {
515
+ const top = widelyRelevant.slice(0, 3);
516
+ insights.push({
517
+ type: "info",
518
+ title: `${widelyRelevant.length} skill${widelyRelevant.length > 1 ? "s" : ""} relevant across 3+ repos`,
519
+ detail: top.map(([name, repos]) => `${name} (${repos.length} repos)`).join(", "),
520
+ action: "Consider adding these skills to your global config",
521
+ });
522
+ }
523
+
524
+ // Health quick wins
525
+ const quickWinRepos = configured
526
+ .filter((r) => r.healthScore > 0 && r.healthScore < 80 && r.healthReasons?.length > 0)
527
+ .sort((a, b) => b.healthScore - a.healthScore)
528
+ .slice(0, 3);
529
+ if (quickWinRepos.length > 0) {
530
+ insights.push({
531
+ type: "tip",
532
+ title: "Quick wins to improve config health",
533
+ detail: quickWinRepos
534
+ .map((r) => `${r.name} (${r.healthScore}/100): ${r.healthReasons[0]}`)
535
+ .join("; "),
536
+ action: "Small changes for measurable improvement",
537
+ });
538
+ }
539
+
540
+ // Insights report nudge
541
+ if (!insightsReport) {
542
+ insights.push({
543
+ type: "info",
544
+ title: "Generate your Claude Code Insights report",
545
+ detail: "Get personalized usage patterns, friction points, and feature suggestions",
546
+ action: "Run /insights in Claude Code",
547
+ });
548
+ }
549
+
550
+ // ── 9. Timestamp ──────────────────────────────────────────────────────
551
+
552
+ const now = new Date();
553
+ const timestamp =
554
+ now
555
+ .toLocaleDateString("en-US", {
556
+ month: "short",
557
+ day: "numeric",
558
+ year: "numeric",
559
+ })
560
+ .toLowerCase() +
561
+ " at " +
562
+ now.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }).toLowerCase();
563
+
564
+ // ── Return ────────────────────────────────────────────────────────────
565
+
566
+ return {
567
+ configured,
568
+ unconfigured,
569
+ globalCmds: raw.globalCmds,
570
+ globalRules: raw.globalRules,
571
+ globalSkills: raw.globalSkills,
572
+ chains: raw.chains,
573
+ mcpSummary,
574
+ mcpPromotions,
575
+ formerMcpServers,
576
+ consolidationGroups,
577
+ usageAnalytics,
578
+ ccusageData,
579
+ statsCache,
580
+ timestamp,
581
+ coveragePct,
582
+ totalRepos,
583
+ configuredCount,
584
+ unconfiguredCount,
585
+ totalRepoCmds,
586
+ avgHealth,
587
+ driftCount,
588
+ mcpCount,
589
+ recommendedMcpServers,
590
+ availableMcpServers,
591
+ registryTotal,
592
+ scanScope: raw.scanScope,
593
+ insights,
594
+ insightsReport,
595
+ };
596
+ }