@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.
@@ -28,39 +28,31 @@ import {
28
28
  CONF,
29
29
  MAX_DEPTH,
30
30
  REPO_URL,
31
- SIMILARITY_THRESHOLD,
32
31
  } from "./src/constants.mjs";
33
32
  import { parseArgs, generateCompletions } from "./src/cli.mjs";
34
33
  import { shortPath } from "./src/helpers.mjs";
35
34
  import { anonymizeAll } from "./src/anonymize.mjs";
36
- import { generateDemoData } from "./src/demo.mjs";
35
+ import { generateDemoRawInputs } from "./src/demo.mjs";
37
36
  import { findGitRepos, getScanRoots } from "./src/discovery.mjs";
38
37
  import { extractProjectDesc, extractSections, scanMdDir } from "./src/markdown.mjs";
39
38
  import { scanSkillsDir, groupSkillsByCategory } from "./src/skills.mjs";
40
39
  import {
41
- computeHealthScore,
42
40
  detectTechStack,
43
- computeDrift,
44
- findExemplar,
45
- generateSuggestions,
46
- detectConfigPattern,
47
- computeConfigSimilarity,
48
- matchSkillsToRepo,
41
+ getGitRevCount,
49
42
  lintConfig,
50
43
  computeDashboardDiff,
51
44
  } from "./src/analysis.mjs";
52
- import { getFreshness, relativeTime, freshnessClass } from "./src/freshness.mjs";
45
+ import { getFreshness } from "./src/freshness.mjs";
53
46
  import {
54
47
  parseUserMcpConfig,
55
48
  parseProjectMcpConfig,
56
- findPromotionCandidates,
57
49
  scanHistoricalMcpServers,
58
- classifyHistoricalServers,
50
+ fetchRegistryServers,
59
51
  } from "./src/mcp.mjs";
60
- import { aggregateSessionMeta } from "./src/usage.mjs";
61
52
  import { handleInit } from "./src/templates.mjs";
62
53
  import { generateCatalogHtml } from "./src/render.mjs";
63
54
  import { generateDashboardHtml } from "./src/assembler.mjs";
55
+ import { buildDashboardData } from "./src/pipeline.mjs";
64
56
  import { startWatch } from "./src/watch.mjs";
65
57
 
66
58
  // ── CLI ──────────────────────────────────────────────────────────────────────
@@ -73,8 +65,9 @@ if (cliArgs.command === "init") handleInit(cliArgs);
73
65
  // ── Demo Mode ────────────────────────────────────────────────────────────────
74
66
 
75
67
  if (cliArgs.demo) {
76
- const demoData = generateDemoData();
77
- const html = generateDashboardHtml(demoData);
68
+ const rawInputs = generateDemoRawInputs();
69
+ const data = buildDashboardData(rawInputs);
70
+ const html = generateDashboardHtml(data);
78
71
 
79
72
  const outputPath = cliArgs.output;
80
73
  mkdirSync(dirname(outputPath), { recursive: true });
@@ -97,593 +90,254 @@ if (cliArgs.demo) {
97
90
  process.exit(0);
98
91
  }
99
92
 
100
- // ── Collect Everything ───────────────────────────────────────────────────────
101
-
102
- const scanRoots = getScanRoots();
103
- const allRepoPaths = findGitRepos(scanRoots, MAX_DEPTH);
104
-
105
- const globalCmds = scanMdDir(join(CLAUDE_DIR, "commands"));
106
- const globalRules = scanMdDir(join(CLAUDE_DIR, "rules"));
107
- const globalSkills = scanSkillsDir(join(CLAUDE_DIR, "skills"));
108
-
109
- const configured = [];
110
- const unconfigured = [];
111
- const seenNames = new Map();
112
-
113
- for (const repoDir of allRepoPaths) {
114
- const name = basename(repoDir);
115
-
116
- // Collision-safe display key
117
- const count = (seenNames.get(name) || 0) + 1;
118
- seenNames.set(name, count);
119
- const key = count > 1 ? `${name}__${count}` : name;
120
-
121
- const repo = {
122
- key,
123
- name,
124
- path: repoDir,
125
- shortPath: shortPath(repoDir),
126
- commands: scanMdDir(join(repoDir, ".claude", "commands")),
127
- rules: scanMdDir(join(repoDir, ".claude", "rules")),
128
- desc: [],
129
- sections: [],
130
- freshness: 0,
131
- freshnessText: "",
132
- freshnessClass: "stale",
133
- };
134
-
135
- // AGENTS.md / CLAUDE.md
136
- let agentsFile = null;
137
- if (existsSync(join(repoDir, "AGENTS.md"))) agentsFile = join(repoDir, "AGENTS.md");
138
- else if (existsSync(join(repoDir, "CLAUDE.md"))) agentsFile = join(repoDir, "CLAUDE.md");
139
-
140
- if (agentsFile) {
141
- repo.desc = extractProjectDesc(agentsFile);
142
- repo.sections = extractSections(agentsFile);
143
- }
144
-
145
- const hasConfig = repo.commands.length > 0 || repo.rules.length > 0 || agentsFile;
146
-
147
- // Tech stack (for both configured and unconfigured)
148
- const stackInfo = detectTechStack(repoDir);
149
- repo.techStack = stackInfo.stacks;
150
-
151
- if (hasConfig) {
152
- repo.freshness = getFreshness(repoDir);
153
- repo.freshnessText = relativeTime(repo.freshness);
154
- repo.freshnessClass = freshnessClass(repo.freshness);
155
-
156
- // Health score
157
- const health = computeHealthScore({
158
- hasAgentsFile: !!agentsFile,
159
- desc: repo.desc,
160
- commandCount: repo.commands.length,
161
- ruleCount: repo.rules.length,
162
- sectionCount: repo.sections.length,
163
- freshnessClass: repo.freshnessClass,
93
+ // ── Collect Raw Inputs ────────────────────────────────────────────────────────
94
+
95
+ async function collectRawInputs() {
96
+ const scanRoots = getScanRoots();
97
+ const allRepoPaths = findGitRepos(scanRoots, MAX_DEPTH);
98
+
99
+ // Global config
100
+ const globalCmds = scanMdDir(join(CLAUDE_DIR, "commands"));
101
+ const globalRules = scanMdDir(join(CLAUDE_DIR, "rules"));
102
+ const globalSkills = scanSkillsDir(join(CLAUDE_DIR, "skills"));
103
+
104
+ // Repo discovery and scanning
105
+ const repos = [];
106
+ for (const repoDir of allRepoPaths) {
107
+ const name = basename(repoDir);
108
+ const commands = scanMdDir(join(repoDir, ".claude", "commands"));
109
+ const rules = scanMdDir(join(repoDir, ".claude", "rules"));
110
+
111
+ // AGENTS.md / CLAUDE.md
112
+ let agentsFile = null;
113
+ if (existsSync(join(repoDir, "AGENTS.md"))) agentsFile = join(repoDir, "AGENTS.md");
114
+ else if (existsSync(join(repoDir, "CLAUDE.md"))) agentsFile = join(repoDir, "CLAUDE.md");
115
+
116
+ const desc = agentsFile ? extractProjectDesc(agentsFile) : [];
117
+ const sections = agentsFile ? extractSections(agentsFile) : [];
118
+
119
+ const stackInfo = detectTechStack(repoDir);
120
+ const hasConfig = commands.length > 0 || rules.length > 0 || agentsFile;
121
+ const freshness = hasConfig ? getFreshness(repoDir) : 0;
122
+
123
+ // Compute gitRevCount for configured repos (used by pipeline for drift)
124
+ const gitRevCount = hasConfig ? getGitRevCount(repoDir, freshness) : null;
125
+
126
+ repos.push({
127
+ name,
128
+ path: repoDir,
129
+ shortPath: shortPath(repoDir),
130
+ commands,
131
+ rules,
132
+ agentsFile,
133
+ desc,
134
+ sections,
135
+ techStack: stackInfo.stacks,
136
+ freshness,
137
+ gitRevCount,
164
138
  });
165
- repo.healthScore = health.score;
166
- repo.healthReasons = health.reasons;
167
- repo.hasAgentsFile = !!agentsFile;
168
- repo.configPattern = detectConfigPattern(repo);
169
-
170
- // Drift detection
171
- const drift = computeDrift(repoDir, repo.freshness);
172
- repo.drift = drift;
173
-
174
- configured.push(repo);
175
- } else {
176
- unconfigured.push(repo);
177
- }
178
- }
179
-
180
- // Sort configured by richness (most config first)
181
- configured.sort((a, b) => {
182
- const score = (r) =>
183
- r.commands.length * 3 + r.rules.length * 2 + r.sections.length + (r.desc.length > 0 ? 1 : 0);
184
- return score(b) - score(a);
185
- });
186
-
187
- unconfigured.sort((a, b) => a.name.localeCompare(b.name));
188
-
189
- // Compute suggestions for unconfigured repos
190
- for (const repo of unconfigured) {
191
- const exemplar = findExemplar(repo.techStack, configured);
192
- if (exemplar) {
193
- repo.suggestions = generateSuggestions(exemplar);
194
- repo.exemplarName = exemplar.name;
195
- } else {
196
- repo.suggestions = [];
197
- repo.exemplarName = "";
198
139
  }
199
- }
200
140
 
201
- // Compute similar repos for configured repos
202
- for (const repo of configured) {
203
- const similar = configured
204
- .filter((r) => r !== repo)
205
- .map((r) => ({ name: r.name, similarity: computeConfigSimilarity(repo, r) }))
206
- .filter((r) => r.similarity >= SIMILARITY_THRESHOLD)
207
- .sort((a, b) => b.similarity - a.similarity)
208
- .slice(0, 2);
209
- repo.similarRepos = similar;
210
- repo.matchedSkills = matchSkillsToRepo(repo, globalSkills);
211
- }
212
-
213
- // Detect consolidation opportunities
214
- const consolidationGroups = [];
215
- const byStack = {};
216
- for (const repo of configured) {
217
- for (const s of repo.techStack || []) {
218
- if (!byStack[s]) byStack[s] = [];
219
- byStack[s].push(repo);
220
- }
221
- }
222
- for (const [stack, repos] of Object.entries(byStack)) {
223
- if (repos.length >= 3) {
224
- let pairCount = 0;
225
- let simSum = 0;
226
- for (let i = 0; i < repos.length; i++) {
227
- for (let j = i + 1; j < repos.length; j++) {
228
- simSum += computeConfigSimilarity(repos[i], repos[j]);
229
- pairCount++;
230
- }
231
- }
232
- const avgSimilarity = pairCount > 0 ? Math.round(simSum / pairCount) : 0;
233
- if (avgSimilarity >= 30) {
234
- consolidationGroups.push({
235
- stack,
236
- repos: repos.map((r) => r.name),
237
- avgSimilarity,
238
- suggestion: `${repos.length} ${stack} repos with ${avgSimilarity}% avg similarity — consider shared global rules`,
239
- });
240
- }
241
- }
242
- }
243
-
244
- // Dependency chains from config
245
- function parseChains() {
246
- if (!existsSync(CONF)) return [];
141
+ // Dependency chains from config
247
142
  const chains = [];
248
- for (const line of readFileSync(CONF, "utf8").split("\n")) {
249
- const m = line.match(/^chain:\s*(.+)/i);
250
- if (!m) continue;
251
- const raw = m[1];
252
- if (raw.includes("<-")) {
253
- chains.push({ nodes: raw.split(/\s*<-\s*/), arrow: "&larr;" });
254
- } else {
255
- chains.push({ nodes: raw.split(/\s*->\s*/), arrow: "&rarr;" });
143
+ if (existsSync(CONF)) {
144
+ for (const line of readFileSync(CONF, "utf8").split("\n")) {
145
+ const m = line.match(/^chain:\s*(.+)/i);
146
+ if (!m) continue;
147
+ const raw = m[1];
148
+ if (raw.includes("<-")) {
149
+ chains.push({ nodes: raw.split(/\s*<-\s*/), arrow: "&larr;" });
150
+ } else {
151
+ chains.push({ nodes: raw.split(/\s*->\s*/), arrow: "&rarr;" });
152
+ }
256
153
  }
257
154
  }
258
- return chains;
259
- }
260
- const chains = parseChains();
261
155
 
262
- // MCP Server Discovery
263
- const claudeJsonPath = join(HOME, ".claude.json");
264
- const allMcpServers = [];
156
+ // MCP Server Discovery
157
+ const claudeJsonPath = join(HOME, ".claude.json");
158
+ const userMcpServers = [];
265
159
 
266
- const userMcpPath = join(CLAUDE_DIR, "mcp_config.json");
267
- if (existsSync(userMcpPath)) {
268
- try {
269
- const content = readFileSync(userMcpPath, "utf8");
270
- allMcpServers.push(...parseUserMcpConfig(content));
271
- } catch {
272
- // skip if unreadable
273
- }
274
- }
275
-
276
- // ~/.claude.json is the primary location where `claude mcp add` writes
277
- let claudeJsonParsed = null;
278
- if (existsSync(claudeJsonPath)) {
279
- try {
280
- const content = readFileSync(claudeJsonPath, "utf8");
281
- claudeJsonParsed = JSON.parse(content);
282
- const existing = new Set(allMcpServers.filter((s) => s.scope === "user").map((s) => s.name));
283
- for (const s of parseUserMcpConfig(content)) {
284
- if (!existing.has(s.name)) allMcpServers.push(s);
285
- }
286
- } catch {
287
- // skip if unreadable
288
- }
289
- }
290
-
291
- for (const repoDir of allRepoPaths) {
292
- const mcpPath = join(repoDir, ".mcp.json");
293
- if (existsSync(mcpPath)) {
160
+ const userMcpPath = join(CLAUDE_DIR, "mcp_config.json");
161
+ if (existsSync(userMcpPath)) {
294
162
  try {
295
- const content = readFileSync(mcpPath, "utf8");
296
- const servers = parseProjectMcpConfig(content, shortPath(repoDir));
297
- allMcpServers.push(...servers);
298
- const repo =
299
- configured.find((r) => r.path === repoDir) || unconfigured.find((r) => r.path === repoDir);
300
- if (repo) repo.mcpServers = servers;
163
+ const content = readFileSync(userMcpPath, "utf8");
164
+ userMcpServers.push(...parseUserMcpConfig(content));
301
165
  } catch {
302
166
  // skip if unreadable
303
167
  }
304
168
  }
305
- }
306
169
 
307
- // Disabled MCP servers
308
- const disabledMcpByRepo = {};
309
- if (claudeJsonParsed) {
310
- try {
311
- const claudeJson = claudeJsonParsed;
312
- for (const [path, entry] of Object.entries(claudeJson)) {
313
- if (
314
- typeof entry === "object" &&
315
- entry !== null &&
316
- Array.isArray(entry.disabledMcpServers) &&
317
- entry.disabledMcpServers.length > 0
318
- ) {
319
- disabledMcpByRepo[path] = entry.disabledMcpServers;
170
+ // ~/.claude.json is the primary location where `claude mcp add` writes
171
+ let claudeJsonParsed = null;
172
+ if (existsSync(claudeJsonPath)) {
173
+ try {
174
+ const content = readFileSync(claudeJsonPath, "utf8");
175
+ claudeJsonParsed = JSON.parse(content);
176
+ const existing = new Set(userMcpServers.filter((s) => s.scope === "user").map((s) => s.name));
177
+ for (const s of parseUserMcpConfig(content)) {
178
+ if (!existing.has(s.name)) userMcpServers.push(s);
320
179
  }
180
+ } catch {
181
+ // skip if unreadable
321
182
  }
322
- } catch {
323
- // skip if parse fails
324
183
  }
325
- }
326
-
327
- const mcpPromotions = findPromotionCandidates(allMcpServers);
328
-
329
- const disabledByServer = {};
330
- for (const [, names] of Object.entries(disabledMcpByRepo)) {
331
- for (const name of names) {
332
- disabledByServer[name] = (disabledByServer[name] || 0) + 1;
333
- }
334
- }
335
-
336
- const mcpByName = {};
337
- for (const s of allMcpServers) {
338
- if (!mcpByName[s.name])
339
- mcpByName[s.name] = {
340
- name: s.name,
341
- type: s.type,
342
- projects: [],
343
- userLevel: false,
344
- disabledIn: 0,
345
- };
346
- if (s.scope === "user") mcpByName[s.name].userLevel = true;
347
- if (s.scope === "project") mcpByName[s.name].projects.push(s.source);
348
- }
349
- for (const entry of Object.values(mcpByName)) {
350
- entry.disabledIn = disabledByServer[entry.name] || 0;
351
- }
352
184
 
353
- const historicalMcpMap = scanHistoricalMcpServers(CLAUDE_DIR);
354
- const currentMcpNames = new Set(allMcpServers.map((s) => s.name));
355
- const { recent: recentMcpServers, former: formerMcpServers } = classifyHistoricalServers(
356
- historicalMcpMap,
357
- currentMcpNames,
358
- );
359
-
360
- // Normalize all historical project paths
361
- for (const server of [...recentMcpServers, ...formerMcpServers]) {
362
- server.projects = server.projects.map((p) => shortPath(p));
363
- }
364
-
365
- // Merge recently-seen servers into allMcpServers so they show up as current
366
- for (const server of recentMcpServers) {
367
- if (!mcpByName[server.name]) {
368
- mcpByName[server.name] = {
369
- name: server.name,
370
- type: "unknown",
371
- projects: server.projects,
372
- userLevel: false,
373
- disabledIn: disabledByServer[server.name] || 0,
374
- recentlyActive: true,
375
- };
376
- }
377
- }
378
- const mcpSummary = Object.values(mcpByName).sort((a, b) => {
379
- if (a.userLevel !== b.userLevel) return a.userLevel ? -1 : 1;
380
- return a.name.localeCompare(b.name);
381
- });
382
- const mcpCount = mcpSummary.length;
383
-
384
- // ── Usage Analytics ──────────────────────────────────────────────────────────
385
-
386
- const SESSION_META_LIMIT = 1000;
387
- const sessionMetaDir = join(CLAUDE_DIR, "usage-data", "session-meta");
388
- const sessionMetaFiles = [];
389
- if (existsSync(sessionMetaDir)) {
390
- try {
391
- const files = readdirSync(sessionMetaDir)
392
- .filter((f) => f.endsWith(".json"))
393
- .sort()
394
- .slice(-SESSION_META_LIMIT);
395
- for (const f of files) {
185
+ // Project MCP servers
186
+ const projectMcpByRepo = {};
187
+ for (const repoDir of allRepoPaths) {
188
+ const mcpPath = join(repoDir, ".mcp.json");
189
+ if (existsSync(mcpPath)) {
396
190
  try {
397
- const content = readFileSync(join(sessionMetaDir, f), "utf8");
398
- sessionMetaFiles.push(JSON.parse(content));
191
+ const content = readFileSync(mcpPath, "utf8");
192
+ const servers = parseProjectMcpConfig(content, shortPath(repoDir));
193
+ projectMcpByRepo[repoDir] = servers;
399
194
  } catch {
400
- // skip unparseable files
195
+ // skip if unreadable
401
196
  }
402
197
  }
403
- } catch {
404
- // skip if directory unreadable
405
198
  }
406
- }
407
- const usageAnalytics = aggregateSessionMeta(sessionMetaFiles);
408
-
409
- // ccusage integration
410
- let ccusageData = null;
411
- const ccusageCachePath = join(CLAUDE_DIR, "ccusage-cache.json");
412
- const CCUSAGE_TTL_MS = 60 * 60 * 1000;
413
199
 
414
- try {
415
- const cached = JSON.parse(readFileSync(ccusageCachePath, "utf8"));
416
- if (cached._ts && Date.now() - cached._ts < CCUSAGE_TTL_MS && cached.totals && cached.daily) {
417
- ccusageData = cached;
418
- }
419
- } catch {
420
- /* no cache or stale */
421
- }
422
-
423
- if (!ccusageData) {
424
- try {
425
- const raw = execFileSync("npx", ["ccusage", "--json"], {
426
- encoding: "utf8",
427
- timeout: 30_000,
428
- stdio: ["pipe", "pipe", "pipe"],
429
- });
430
- const parsed = JSON.parse(raw);
431
- if (parsed.totals && parsed.daily) {
432
- ccusageData = parsed;
433
- try {
434
- writeFileSync(ccusageCachePath, JSON.stringify({ ...parsed, _ts: Date.now() }));
435
- } catch {
436
- /* non-critical */
200
+ // Disabled MCP servers
201
+ const disabledMcpByRepo = {};
202
+ if (claudeJsonParsed) {
203
+ try {
204
+ for (const [path, entry] of Object.entries(claudeJsonParsed)) {
205
+ if (
206
+ typeof entry === "object" &&
207
+ entry !== null &&
208
+ Array.isArray(entry.disabledMcpServers) &&
209
+ entry.disabledMcpServers.length > 0
210
+ ) {
211
+ disabledMcpByRepo[path] = entry.disabledMcpServers;
212
+ }
437
213
  }
214
+ } catch {
215
+ // skip if parse fails
438
216
  }
439
- } catch {
440
- // ccusage not installed or timed out
441
217
  }
442
- }
443
-
444
- // Claude Code Insights report (generated by /insights)
445
- let insightsReport = null;
446
- const reportPath = join(CLAUDE_DIR, "usage-data", "report.html");
447
- if (existsSync(reportPath)) {
448
- try {
449
- const reportHtml = readFileSync(reportPath, "utf8");
450
-
451
- // Extract subtitle — reformat ISO dates to readable format
452
- const subtitleMatch = reportHtml.match(/<p class="subtitle">([^<]+)<\/p>/);
453
- let subtitle = subtitleMatch ? subtitleMatch[1] : null;
454
- if (subtitle) {
455
- subtitle = subtitle.replace(/(\d{4})-(\d{2})-(\d{2})/g, (_, y, m2, d) => {
456
- const dt = new Date(`${y}-${m2}-${d}T00:00:00Z`);
457
- return dt.toLocaleDateString("en-US", {
458
- month: "short",
459
- day: "numeric",
460
- year: "numeric",
461
- timeZone: "UTC",
462
- });
463
- });
464
- }
465
-
466
- // Extract glance sections (content may contain <strong> tags)
467
- const glanceSections = [];
468
- const glanceRe =
469
- /<div class="glance-section"><strong>([^<]+)<\/strong>\s*([\s\S]*?)<a[^>]*class="see-more"/g;
470
- let m;
471
- while ((m = glanceRe.exec(reportHtml)) !== null) {
472
- const text = m[2].replace(/<[^>]+>/g, "").trim();
473
- glanceSections.push({ label: m[1].replace(/:$/, ""), text });
474
- }
475
218
 
476
- // Extract stats
477
- const statsRe = /<div class="stat-value">([^<]+)<\/div><div class="stat-label">([^<]+)<\/div>/g;
478
- const reportStats = [];
479
- while ((m = statsRe.exec(reportHtml)) !== null) {
480
- const value = m[1];
481
- const label = m[2];
482
- // Mark lines stat for diff-style rendering
483
- const isDiff = /^[+-]/.test(value) && value.includes("/");
484
- reportStats.push({ value, label, isDiff });
485
- }
219
+ // Historical MCP servers — normalize project paths here (I/O side)
220
+ const historicalMcpMap = scanHistoricalMcpServers(CLAUDE_DIR);
221
+ for (const [, entry] of historicalMcpMap) {
222
+ entry.projects = new Set([...entry.projects].map((p) => shortPath(p)));
223
+ }
486
224
 
487
- // Extract friction categories
488
- const frictionRe =
489
- /<div class="friction-title">([^<]+)<\/div>\s*<div class="friction-desc">([^<]+)<\/div>/g;
490
- const frictionPoints = [];
491
- while ((m = frictionRe.exec(reportHtml)) !== null) {
492
- frictionPoints.push({ title: m[1], desc: m[2] });
493
- }
225
+ // MCP Registry servers
226
+ const registryServers = cliArgs.offline ? [] : await fetchRegistryServers();
494
227
 
495
- if (glanceSections.length > 0 || reportStats.length > 0) {
496
- insightsReport = {
497
- subtitle,
498
- glance: glanceSections,
499
- stats: reportStats,
500
- friction: frictionPoints.slice(0, 3),
501
- filePath: reportPath,
502
- };
228
+ // Usage data session meta files
229
+ const SESSION_META_LIMIT = 1000;
230
+ const sessionMetaDir = join(CLAUDE_DIR, "usage-data", "session-meta");
231
+ const sessionMetaFiles = [];
232
+ if (existsSync(sessionMetaDir)) {
233
+ try {
234
+ const files = readdirSync(sessionMetaDir)
235
+ .filter((f) => f.endsWith(".json"))
236
+ .sort()
237
+ .slice(-SESSION_META_LIMIT);
238
+ for (const f of files) {
239
+ try {
240
+ const content = readFileSync(join(sessionMetaDir, f), "utf8");
241
+ sessionMetaFiles.push(JSON.parse(content));
242
+ } catch {
243
+ // skip unparseable files
244
+ }
245
+ }
246
+ } catch {
247
+ // skip if directory unreadable
503
248
  }
504
- } catch {
505
- // skip if unreadable
506
249
  }
507
- }
508
250
 
509
- // Stats cache
510
- const statsCachePath = join(CLAUDE_DIR, "stats-cache.json");
511
- let statsCache = {};
512
- if (existsSync(statsCachePath)) {
251
+ // ccusage integration
252
+ let ccusageData = null;
253
+ const ccusageCachePath = join(CLAUDE_DIR, "ccusage-cache.json");
254
+ const CCUSAGE_TTL_MS = 60 * 60 * 1000;
255
+
513
256
  try {
514
- statsCache = JSON.parse(readFileSync(statsCachePath, "utf8"));
257
+ const cached = JSON.parse(readFileSync(ccusageCachePath, "utf8"));
258
+ if (cached._ts && Date.now() - cached._ts < CCUSAGE_TTL_MS && cached.totals && cached.daily) {
259
+ ccusageData = cached;
260
+ }
515
261
  } catch {
516
- // skip if parse fails
262
+ /* no cache or stale */
517
263
  }
518
- }
519
264
 
520
- // Supplement dailyActivity with session-meta data
521
- if (sessionMetaFiles.length > 0) {
522
- const existingDates = new Set((statsCache.dailyActivity || []).map((d) => d.date));
523
- const sessionDayCounts = {};
524
- for (const s of sessionMetaFiles) {
525
- const date = (s.start_time || "").slice(0, 10);
526
- if (!date || existingDates.has(date)) continue;
527
- sessionDayCounts[date] =
528
- (sessionDayCounts[date] || 0) +
529
- (s.user_message_count || 0) +
530
- (s.assistant_message_count || 0);
531
- }
532
- const supplemental = Object.entries(sessionDayCounts).map(([date, messageCount]) => ({
533
- date,
534
- messageCount,
535
- }));
536
- if (supplemental.length > 0) {
537
- statsCache.dailyActivity = [...(statsCache.dailyActivity || []), ...supplemental].sort((a, b) =>
538
- a.date.localeCompare(b.date),
539
- );
540
- }
541
- }
542
-
543
- // Supplement dailyActivity with ccusage data (fills gaps like Feb 17-22)
544
- if (ccusageData && ccusageData.daily) {
545
- const existingDates = new Set((statsCache.dailyActivity || []).map((d) => d.date));
546
- const ccusageSupplemental = ccusageData.daily
547
- .filter((d) => d.date && !existingDates.has(d.date) && d.totalTokens > 0)
548
- .map((d) => ({ date: d.date, messageCount: Math.max(1, Math.round(d.totalTokens / 10000)) }));
549
- if (ccusageSupplemental.length > 0) {
550
- statsCache.dailyActivity = [...(statsCache.dailyActivity || []), ...ccusageSupplemental].sort(
551
- (a, b) => a.date.localeCompare(b.date),
552
- );
265
+ if (!ccusageData) {
266
+ try {
267
+ const raw = execFileSync("npx", ["ccusage", "--json"], {
268
+ encoding: "utf8",
269
+ timeout: 30_000,
270
+ stdio: ["pipe", "pipe", "pipe"],
271
+ });
272
+ const parsed = JSON.parse(raw);
273
+ if (parsed.totals && parsed.daily) {
274
+ ccusageData = parsed;
275
+ try {
276
+ writeFileSync(ccusageCachePath, JSON.stringify({ ...parsed, _ts: Date.now() }));
277
+ } catch {
278
+ /* non-critical */
279
+ }
280
+ }
281
+ } catch {
282
+ // ccusage not installed or timed out
283
+ }
553
284
  }
554
- }
555
285
 
556
- // ── Computed Stats ───────────────────────────────────────────────────────────
557
-
558
- const totalRepos = allRepoPaths.length;
559
- const configuredCount = configured.length;
560
- const unconfiguredCount = unconfigured.length;
561
- const coveragePct = totalRepos > 0 ? Math.round((configuredCount / totalRepos) * 100) : 0;
562
- const totalRepoCmds = configured.reduce((sum, r) => sum + r.commands.length, 0);
563
- const avgHealth =
564
- configured.length > 0
565
- ? Math.round(configured.reduce((sum, r) => sum + (r.healthScore || 0), 0) / configured.length)
566
- : 0;
567
- const driftCount = configured.filter(
568
- (r) => r.drift && (r.drift.level === "medium" || r.drift.level === "high"),
569
- ).length;
570
-
571
- // ── Insights ──────────────────────────────────────────────────────────────────
572
- const insights = [];
573
-
574
- // Drift alerts
575
- const highDriftRepos = configured.filter((r) => r.drift?.level === "high");
576
- if (highDriftRepos.length > 0) {
577
- insights.push({
578
- type: "warning",
579
- title: `${highDriftRepos.length} repo${highDriftRepos.length > 1 ? "s have" : " has"} high config drift`,
580
- detail: highDriftRepos
581
- .map((r) => `${r.name} (${r.drift.commitsSince} commits since config update)`)
582
- .join(", "),
583
- action: "Review and update CLAUDE.md in these repos",
584
- });
585
- }
586
-
587
- // Coverage
588
- if (unconfigured.length > 0 && totalRepos > 0) {
589
- const pct = Math.round((unconfigured.length / totalRepos) * 100);
590
- if (pct >= 40) {
591
- const withStack = unconfigured.filter((r) => r.techStack?.length > 0).slice(0, 3);
592
- insights.push({
593
- type: "info",
594
- title: `${unconfigured.length} repos unconfigured (${pct}%)`,
595
- detail: withStack.length
596
- ? `Top candidates: ${withStack.map((r) => `${r.name} (${r.techStack.join(", ")})`).join(", ")}`
597
- : "",
598
- action: "Run claude-code-dashboard init --template <stack> in these repos",
599
- });
286
+ // Claude Code Insights report — read raw HTML, pipeline parses it
287
+ let insightsReportHtml = null;
288
+ const reportPath = join(CLAUDE_DIR, "usage-data", "report.html");
289
+ if (existsSync(reportPath)) {
290
+ try {
291
+ insightsReportHtml = readFileSync(reportPath, "utf8");
292
+ } catch {
293
+ // skip if unreadable
294
+ }
600
295
  }
601
- }
602
296
 
603
- // MCP promotions
604
- if (mcpPromotions.length > 0) {
605
- insights.push({
606
- type: "promote",
607
- title: `${mcpPromotions.length} MCP server${mcpPromotions.length > 1 ? "s" : ""} could be promoted to global`,
608
- detail: mcpPromotions.map((p) => `${p.name} (in ${p.projects.length} projects)`).join(", "),
609
- action: "Add to ~/.claude/mcp_config.json for all projects",
610
- });
611
- }
612
-
613
- // Redundant project-scope MCP configs (global server also in project .mcp.json)
614
- const redundantMcp = Object.values(mcpByName).filter((s) => s.userLevel && s.projects.length > 0);
615
- if (redundantMcp.length > 0) {
616
- insights.push({
617
- type: "tip",
618
- title: `${redundantMcp.length} MCP server${redundantMcp.length > 1 ? "s are" : " is"} global but also in project .mcp.json`,
619
- detail: redundantMcp.map((s) => `${s.name} (${s.projects.join(", ")})`).join("; "),
620
- action: "Remove from project .mcp.json — global config already covers all projects",
621
- });
622
- }
623
-
624
- // Skill sharing opportunities
625
- const skillMatchCounts = {};
626
- for (const r of configured) {
627
- for (const sk of r.matchedSkills || []) {
628
- const skName = typeof sk === "string" ? sk : sk.name;
629
- if (!skillMatchCounts[skName]) skillMatchCounts[skName] = [];
630
- skillMatchCounts[skName].push(r.name);
297
+ // Stats cache
298
+ const statsCachePath = join(CLAUDE_DIR, "stats-cache.json");
299
+ let statsCache = {};
300
+ if (existsSync(statsCachePath)) {
301
+ try {
302
+ statsCache = JSON.parse(readFileSync(statsCachePath, "utf8"));
303
+ } catch {
304
+ // skip if parse fails
305
+ }
631
306
  }
632
- }
633
- const widelyRelevant = Object.entries(skillMatchCounts)
634
- .filter(([, repos]) => repos.length >= 3)
635
- .sort((a, b) => b[1].length - a[1].length);
636
- if (widelyRelevant.length > 0) {
637
- const top = widelyRelevant.slice(0, 3);
638
- insights.push({
639
- type: "info",
640
- title: `${widelyRelevant.length} skill${widelyRelevant.length > 1 ? "s" : ""} relevant across 3+ repos`,
641
- detail: top.map(([name, repos]) => `${name} (${repos.length} repos)`).join(", "),
642
- action: "Consider adding these skills to your global config",
643
- });
644
- }
645
307
 
646
- // Health quick wins — repos closest to next tier
647
- const quickWinRepos = configured
648
- .filter((r) => r.healthScore > 0 && r.healthScore < 80 && r.healthReasons?.length > 0)
649
- .sort((a, b) => b.healthScore - a.healthScore)
650
- .slice(0, 3);
651
- if (quickWinRepos.length > 0) {
652
- insights.push({
653
- type: "tip",
654
- title: "Quick wins to improve config health",
655
- detail: quickWinRepos
656
- .map((r) => `${r.name} (${r.healthScore}/100): ${r.healthReasons[0]}`)
657
- .join("; "),
658
- action: "Small changes for measurable improvement",
659
- });
660
- }
308
+ // Scan scope
309
+ const scanScope = existsSync(CONF) ? `config: ${shortPath(CONF)}` : "~/ (depth 5)";
661
310
 
662
- // Insights report nudge
663
- if (!insightsReport) {
664
- insights.push({
665
- type: "info",
666
- title: "Generate your Claude Code Insights report",
667
- detail: "Get personalized usage patterns, friction points, and feature suggestions",
668
- action: "Run /insights in Claude Code",
669
- });
311
+ return {
312
+ repos,
313
+ globalCmds,
314
+ globalRules,
315
+ globalSkills,
316
+ userMcpServers,
317
+ projectMcpByRepo,
318
+ disabledMcpByRepo,
319
+ historicalMcpMap,
320
+ registryServers,
321
+ sessionMetaFiles,
322
+ ccusageData,
323
+ statsCache,
324
+ insightsReportHtml,
325
+ chains,
326
+ scanScope,
327
+ insightsReportPath: reportPath,
328
+ };
670
329
  }
671
330
 
672
- const now = new Date();
673
- const timestamp =
674
- now
675
- .toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
676
- .toLowerCase() +
677
- " at " +
678
- now.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }).toLowerCase();
331
+ // ── Build Dashboard Data ─────────────────────────────────────────────────────
679
332
 
680
- const scanScope = existsSync(CONF) ? `config: ${shortPath(CONF)}` : "~/ (depth 5)";
333
+ const rawInputs = await collectRawInputs();
334
+ const data = buildDashboardData(rawInputs);
681
335
 
682
336
  // ── Lint Subcommand ──────────────────────────────────────────────────────────
683
337
 
684
338
  if (cliArgs.command === "lint") {
685
339
  let totalIssues = 0;
686
- for (const repo of configured) {
340
+ for (const repo of data.configured) {
687
341
  const issues = lintConfig(repo);
688
342
  if (issues.length === 0) continue;
689
343
  if (!cliArgs.quiet) console.log(`\n${repo.name} (${repo.shortPath}):`);
@@ -705,7 +359,10 @@ if (cliArgs.command === "lint") {
705
359
  const SNAPSHOT_PATH = join(CLAUDE_DIR, "dashboard-snapshot.json");
706
360
  if (cliArgs.diff) {
707
361
  const currentSnapshot = {
708
- configuredRepos: configured.map((r) => ({ name: r.name, healthScore: r.healthScore || 0 })),
362
+ configuredRepos: data.configured.map((r) => ({
363
+ name: r.name,
364
+ healthScore: r.healthScore || 0,
365
+ })),
709
366
  };
710
367
  if (existsSync(SNAPSHOT_PATH)) {
711
368
  try {
@@ -732,59 +389,60 @@ if (cliArgs.diff) {
732
389
 
733
390
  if (cliArgs.anonymize) {
734
391
  anonymizeAll({
735
- configured,
736
- unconfigured,
737
- globalCmds,
738
- globalRules,
739
- globalSkills,
740
- chains,
741
- mcpSummary,
742
- mcpPromotions,
743
- formerMcpServers,
744
- consolidationGroups,
392
+ configured: data.configured,
393
+ unconfigured: data.unconfigured,
394
+ globalCmds: data.globalCmds,
395
+ globalRules: data.globalRules,
396
+ globalSkills: data.globalSkills,
397
+ chains: data.chains,
398
+ mcpSummary: data.mcpSummary,
399
+ mcpPromotions: data.mcpPromotions,
400
+ formerMcpServers: data.formerMcpServers,
401
+ consolidationGroups: data.consolidationGroups,
745
402
  });
746
403
  }
747
404
 
748
405
  // ── JSON Output ──────────────────────────────────────────────────────────────
749
406
 
750
407
  if (cliArgs.json) {
408
+ const now = new Date();
751
409
  const jsonData = {
752
410
  version: VERSION,
753
411
  generatedAt: now.toISOString(),
754
- scanScope,
412
+ scanScope: data.scanScope,
755
413
  stats: {
756
- totalRepos,
757
- configuredRepos: configuredCount,
758
- unconfiguredRepos: unconfiguredCount,
759
- coveragePct,
760
- globalCommands: globalCmds.length,
761
- globalRules: globalRules.length,
762
- skills: globalSkills.length,
763
- repoCommands: totalRepoCmds,
764
- avgHealthScore: avgHealth,
765
- driftingRepos: driftCount,
766
- mcpServers: mcpCount,
767
- ...(ccusageData
414
+ totalRepos: data.totalRepos,
415
+ configuredRepos: data.configuredCount,
416
+ unconfiguredRepos: data.unconfiguredCount,
417
+ coveragePct: data.coveragePct,
418
+ globalCommands: data.globalCmds.length,
419
+ globalRules: data.globalRules.length,
420
+ skills: data.globalSkills.length,
421
+ repoCommands: data.totalRepoCmds,
422
+ avgHealthScore: data.avgHealth,
423
+ driftingRepos: data.driftCount,
424
+ mcpServers: data.mcpCount,
425
+ ...(data.ccusageData
768
426
  ? {
769
- totalCost: ccusageData.totals.totalCost,
770
- totalTokens: ccusageData.totals.totalTokens,
427
+ totalCost: data.ccusageData.totals.totalCost,
428
+ totalTokens: data.ccusageData.totals.totalTokens,
771
429
  }
772
430
  : {}),
773
- errorCategories: usageAnalytics.errorCategories,
431
+ errorCategories: data.usageAnalytics.errorCategories,
774
432
  },
775
- globalCommands: globalCmds.map((c) => ({ name: c.name, description: c.desc })),
776
- globalRules: globalRules.map((r) => ({ name: r.name, description: r.desc })),
777
- skills: globalSkills.map((s) => ({
433
+ globalCommands: data.globalCmds.map((c) => ({ name: c.name, description: c.desc })),
434
+ globalRules: data.globalRules.map((r) => ({ name: r.name, description: r.desc })),
435
+ skills: data.globalSkills.map((s) => ({
778
436
  name: s.name,
779
437
  description: s.desc,
780
438
  source: s.source,
781
439
  category: s.category,
782
440
  })),
783
- chains: chains.map((c) => ({
441
+ chains: data.chains.map((c) => ({
784
442
  nodes: c.nodes.map((n) => n.trim()),
785
443
  direction: c.arrow === "&rarr;" ? "forward" : "backward",
786
444
  })),
787
- configuredRepos: configured.map((r) => ({
445
+ configuredRepos: data.configured.map((r) => ({
788
446
  name: r.name,
789
447
  path: r.shortPath,
790
448
  commands: r.commands.map((c) => ({ name: c.name, description: c.desc })),
@@ -805,8 +463,8 @@ if (cliArgs.json) {
805
463
  similarRepos: r.similarRepos || [],
806
464
  mcpServers: r.mcpServers || [],
807
465
  })),
808
- consolidationGroups,
809
- unconfiguredRepos: unconfigured.map((r) => ({
466
+ consolidationGroups: data.consolidationGroups,
467
+ unconfiguredRepos: data.unconfigured.map((r) => ({
810
468
  name: r.name,
811
469
  path: r.shortPath,
812
470
  techStack: r.techStack || [],
@@ -814,9 +472,9 @@ if (cliArgs.json) {
814
472
  exemplar: r.exemplarName || "",
815
473
  mcpServers: r.mcpServers || [],
816
474
  })),
817
- mcpServers: mcpSummary,
818
- mcpPromotions,
819
- formerMcpServers,
475
+ mcpServers: data.mcpSummary,
476
+ mcpPromotions: data.mcpPromotions,
477
+ formerMcpServers: data.formerMcpServers,
820
478
  };
821
479
 
822
480
  const jsonOutput = JSON.stringify(jsonData, null, 2);
@@ -834,8 +492,8 @@ if (cliArgs.json) {
834
492
  // ── Catalog Output ───────────────────────────────────────────────────────────
835
493
 
836
494
  if (cliArgs.catalog) {
837
- const groups = groupSkillsByCategory(globalSkills);
838
- const catalogHtml = generateCatalogHtml(groups, globalSkills.length, timestamp);
495
+ const groups = groupSkillsByCategory(data.globalSkills);
496
+ const catalogHtml = generateCatalogHtml(groups, data.globalSkills.length, data.timestamp);
839
497
  const outputPath =
840
498
  cliArgs.output !== DEFAULT_OUTPUT ? cliArgs.output : join(CLAUDE_DIR, "skill-catalog.html");
841
499
  mkdirSync(dirname(outputPath), { recursive: true });
@@ -851,33 +509,7 @@ if (cliArgs.catalog) {
851
509
 
852
510
  // ── Generate HTML Dashboard ──────────────────────────────────────────────────
853
511
 
854
- const html = generateDashboardHtml({
855
- configured,
856
- unconfigured,
857
- globalCmds,
858
- globalRules,
859
- globalSkills,
860
- chains,
861
- mcpSummary,
862
- mcpPromotions,
863
- formerMcpServers,
864
- consolidationGroups,
865
- usageAnalytics,
866
- ccusageData,
867
- statsCache,
868
- timestamp,
869
- coveragePct,
870
- totalRepos,
871
- configuredCount,
872
- unconfiguredCount,
873
- totalRepoCmds,
874
- avgHealth,
875
- driftCount,
876
- mcpCount,
877
- scanScope,
878
- insights,
879
- insightsReport,
880
- });
512
+ const html = generateDashboardHtml(data);
881
513
 
882
514
  // ── Write HTML Output ────────────────────────────────────────────────────────
883
515
 
@@ -889,10 +521,10 @@ if (!cliArgs.quiet) {
889
521
  const sp = shortPath(outputPath);
890
522
  console.log(`\n claude-code-dashboard v${VERSION}\n`);
891
523
  console.log(
892
- ` ${configuredCount} configured · ${unconfiguredCount} unconfigured · ${totalRepos} repos`,
524
+ ` ${data.configuredCount} configured · ${data.unconfiguredCount} unconfigured · ${data.totalRepos} repos`,
893
525
  );
894
526
  console.log(
895
- ` ${globalCmds.length} global commands · ${globalSkills.length} skills · ${mcpCount} MCP servers`,
527
+ ` ${data.globalCmds.length} global commands · ${data.globalSkills.length} skills · ${data.mcpCount} MCP servers`,
896
528
  );
897
529
  console.log(`\n ✓ ${sp}`);
898
530
  if (cliArgs.open) console.log(` ✓ opening in browser`);
@@ -909,5 +541,5 @@ if (cliArgs.open) {
909
541
  // ── Watch Mode ───────────────────────────────────────────────────────────────
910
542
 
911
543
  if (cliArgs.watch) {
912
- startWatch(outputPath, scanRoots, cliArgs);
544
+ startWatch(outputPath, getScanRoots(), cliArgs);
913
545
  }