@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/README.md +2 -0
- package/generate-dashboard.mjs +251 -619
- package/package.json +1 -1
- package/src/analysis.mjs +19 -12
- package/src/assembler.mjs +4 -1
- package/src/cli.mjs +7 -2
- package/src/constants.mjs +39 -1
- package/src/demo.mjs +269 -248
- package/src/mcp.mjs +97 -2
- package/src/pipeline.mjs +596 -0
- package/src/sections.mjs +50 -2
- package/template/dashboard.css +78 -0
- package/template/dashboard.js +18 -0
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 {
|
|
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
|
+
}
|
package/src/pipeline.mjs
ADDED
|
@@ -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
|
+
}
|