@viren/claude-code-dashboard 0.0.6 → 0.0.7

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,26 @@ 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";
53
- import {
54
- parseUserMcpConfig,
55
- parseProjectMcpConfig,
56
- findPromotionCandidates,
57
- scanHistoricalMcpServers,
58
- classifyHistoricalServers,
59
- } from "./src/mcp.mjs";
60
- import { aggregateSessionMeta } from "./src/usage.mjs";
45
+ import { getFreshness } from "./src/freshness.mjs";
46
+ import { parseUserMcpConfig, parseProjectMcpConfig, scanHistoricalMcpServers } from "./src/mcp.mjs";
61
47
  import { handleInit } from "./src/templates.mjs";
62
48
  import { generateCatalogHtml } from "./src/render.mjs";
63
49
  import { generateDashboardHtml } from "./src/assembler.mjs";
50
+ import { buildDashboardData } from "./src/pipeline.mjs";
64
51
  import { startWatch } from "./src/watch.mjs";
65
52
 
66
53
  // ── CLI ──────────────────────────────────────────────────────────────────────
@@ -73,8 +60,9 @@ if (cliArgs.command === "init") handleInit(cliArgs);
73
60
  // ── Demo Mode ────────────────────────────────────────────────────────────────
74
61
 
75
62
  if (cliArgs.demo) {
76
- const demoData = generateDemoData();
77
- const html = generateDashboardHtml(demoData);
63
+ const rawInputs = generateDemoRawInputs();
64
+ const data = buildDashboardData(rawInputs);
65
+ const html = generateDashboardHtml(data);
78
66
 
79
67
  const outputPath = cliArgs.output;
80
68
  mkdirSync(dirname(outputPath), { recursive: true });
@@ -97,593 +85,250 @@ if (cliArgs.demo) {
97
85
  process.exit(0);
98
86
  }
99
87
 
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,
88
+ // ── Collect Raw Inputs ────────────────────────────────────────────────────────
89
+
90
+ function collectRawInputs() {
91
+ const scanRoots = getScanRoots();
92
+ const allRepoPaths = findGitRepos(scanRoots, MAX_DEPTH);
93
+
94
+ // Global config
95
+ const globalCmds = scanMdDir(join(CLAUDE_DIR, "commands"));
96
+ const globalRules = scanMdDir(join(CLAUDE_DIR, "rules"));
97
+ const globalSkills = scanSkillsDir(join(CLAUDE_DIR, "skills"));
98
+
99
+ // Repo discovery and scanning
100
+ const repos = [];
101
+ for (const repoDir of allRepoPaths) {
102
+ const name = basename(repoDir);
103
+ const commands = scanMdDir(join(repoDir, ".claude", "commands"));
104
+ const rules = scanMdDir(join(repoDir, ".claude", "rules"));
105
+
106
+ // AGENTS.md / CLAUDE.md
107
+ let agentsFile = null;
108
+ if (existsSync(join(repoDir, "AGENTS.md"))) agentsFile = join(repoDir, "AGENTS.md");
109
+ else if (existsSync(join(repoDir, "CLAUDE.md"))) agentsFile = join(repoDir, "CLAUDE.md");
110
+
111
+ const desc = agentsFile ? extractProjectDesc(agentsFile) : [];
112
+ const sections = agentsFile ? extractSections(agentsFile) : [];
113
+
114
+ const stackInfo = detectTechStack(repoDir);
115
+ const hasConfig = commands.length > 0 || rules.length > 0 || agentsFile;
116
+ const freshness = hasConfig ? getFreshness(repoDir) : 0;
117
+
118
+ // Compute gitRevCount for configured repos (used by pipeline for drift)
119
+ const gitRevCount = hasConfig ? getGitRevCount(repoDir, freshness) : null;
120
+
121
+ repos.push({
122
+ name,
123
+ path: repoDir,
124
+ shortPath: shortPath(repoDir),
125
+ commands,
126
+ rules,
127
+ agentsFile,
128
+ desc,
129
+ sections,
130
+ techStack: stackInfo.stacks,
131
+ freshness,
132
+ gitRevCount,
164
133
  });
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
134
  }
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
- }
199
- }
200
135
 
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 [];
136
+ // Dependency chains from config
247
137
  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;" });
138
+ if (existsSync(CONF)) {
139
+ for (const line of readFileSync(CONF, "utf8").split("\n")) {
140
+ const m = line.match(/^chain:\s*(.+)/i);
141
+ if (!m) continue;
142
+ const raw = m[1];
143
+ if (raw.includes("<-")) {
144
+ chains.push({ nodes: raw.split(/\s*<-\s*/), arrow: "&larr;" });
145
+ } else {
146
+ chains.push({ nodes: raw.split(/\s*->\s*/), arrow: "&rarr;" });
147
+ }
256
148
  }
257
149
  }
258
- return chains;
259
- }
260
- const chains = parseChains();
261
150
 
262
- // MCP Server Discovery
263
- const claudeJsonPath = join(HOME, ".claude.json");
264
- const allMcpServers = [];
265
-
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
- }
151
+ // MCP Server Discovery
152
+ const claudeJsonPath = join(HOME, ".claude.json");
153
+ const userMcpServers = [];
290
154
 
291
- for (const repoDir of allRepoPaths) {
292
- const mcpPath = join(repoDir, ".mcp.json");
293
- if (existsSync(mcpPath)) {
155
+ const userMcpPath = join(CLAUDE_DIR, "mcp_config.json");
156
+ if (existsSync(userMcpPath)) {
294
157
  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;
158
+ const content = readFileSync(userMcpPath, "utf8");
159
+ userMcpServers.push(...parseUserMcpConfig(content));
301
160
  } catch {
302
161
  // skip if unreadable
303
162
  }
304
163
  }
305
- }
306
164
 
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;
165
+ // ~/.claude.json is the primary location where `claude mcp add` writes
166
+ let claudeJsonParsed = null;
167
+ if (existsSync(claudeJsonPath)) {
168
+ try {
169
+ const content = readFileSync(claudeJsonPath, "utf8");
170
+ claudeJsonParsed = JSON.parse(content);
171
+ const existing = new Set(userMcpServers.filter((s) => s.scope === "user").map((s) => s.name));
172
+ for (const s of parseUserMcpConfig(content)) {
173
+ if (!existing.has(s.name)) userMcpServers.push(s);
320
174
  }
175
+ } catch {
176
+ // skip if unreadable
321
177
  }
322
- } catch {
323
- // skip if parse fails
324
178
  }
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
179
 
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) {
180
+ // Project MCP servers
181
+ const projectMcpByRepo = {};
182
+ for (const repoDir of allRepoPaths) {
183
+ const mcpPath = join(repoDir, ".mcp.json");
184
+ if (existsSync(mcpPath)) {
396
185
  try {
397
- const content = readFileSync(join(sessionMetaDir, f), "utf8");
398
- sessionMetaFiles.push(JSON.parse(content));
186
+ const content = readFileSync(mcpPath, "utf8");
187
+ const servers = parseProjectMcpConfig(content, shortPath(repoDir));
188
+ projectMcpByRepo[repoDir] = servers;
399
189
  } catch {
400
- // skip unparseable files
190
+ // skip if unreadable
401
191
  }
402
192
  }
403
- } catch {
404
- // skip if directory unreadable
405
193
  }
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
194
 
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 */
195
+ // Disabled MCP servers
196
+ const disabledMcpByRepo = {};
197
+ if (claudeJsonParsed) {
198
+ try {
199
+ for (const [path, entry] of Object.entries(claudeJsonParsed)) {
200
+ if (
201
+ typeof entry === "object" &&
202
+ entry !== null &&
203
+ Array.isArray(entry.disabledMcpServers) &&
204
+ entry.disabledMcpServers.length > 0
205
+ ) {
206
+ disabledMcpByRepo[path] = entry.disabledMcpServers;
207
+ }
437
208
  }
209
+ } catch {
210
+ // skip if parse fails
438
211
  }
439
- } catch {
440
- // ccusage not installed or timed out
441
212
  }
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
213
 
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
- }
486
-
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
- }
214
+ // Historical MCP servers — normalize project paths here (I/O side)
215
+ const historicalMcpMap = scanHistoricalMcpServers(CLAUDE_DIR);
216
+ for (const [, entry] of historicalMcpMap) {
217
+ entry.projects = new Set([...entry.projects].map((p) => shortPath(p)));
218
+ }
494
219
 
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
- };
220
+ // Usage data session meta files
221
+ const SESSION_META_LIMIT = 1000;
222
+ const sessionMetaDir = join(CLAUDE_DIR, "usage-data", "session-meta");
223
+ const sessionMetaFiles = [];
224
+ if (existsSync(sessionMetaDir)) {
225
+ try {
226
+ const files = readdirSync(sessionMetaDir)
227
+ .filter((f) => f.endsWith(".json"))
228
+ .sort()
229
+ .slice(-SESSION_META_LIMIT);
230
+ for (const f of files) {
231
+ try {
232
+ const content = readFileSync(join(sessionMetaDir, f), "utf8");
233
+ sessionMetaFiles.push(JSON.parse(content));
234
+ } catch {
235
+ // skip unparseable files
236
+ }
237
+ }
238
+ } catch {
239
+ // skip if directory unreadable
503
240
  }
504
- } catch {
505
- // skip if unreadable
506
241
  }
507
- }
508
242
 
509
- // Stats cache
510
- const statsCachePath = join(CLAUDE_DIR, "stats-cache.json");
511
- let statsCache = {};
512
- if (existsSync(statsCachePath)) {
243
+ // ccusage integration
244
+ let ccusageData = null;
245
+ const ccusageCachePath = join(CLAUDE_DIR, "ccusage-cache.json");
246
+ const CCUSAGE_TTL_MS = 60 * 60 * 1000;
247
+
513
248
  try {
514
- statsCache = JSON.parse(readFileSync(statsCachePath, "utf8"));
249
+ const cached = JSON.parse(readFileSync(ccusageCachePath, "utf8"));
250
+ if (cached._ts && Date.now() - cached._ts < CCUSAGE_TTL_MS && cached.totals && cached.daily) {
251
+ ccusageData = cached;
252
+ }
515
253
  } catch {
516
- // skip if parse fails
254
+ /* no cache or stale */
517
255
  }
518
- }
519
256
 
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
- );
257
+ if (!ccusageData) {
258
+ try {
259
+ const raw = execFileSync("npx", ["ccusage", "--json"], {
260
+ encoding: "utf8",
261
+ timeout: 30_000,
262
+ stdio: ["pipe", "pipe", "pipe"],
263
+ });
264
+ const parsed = JSON.parse(raw);
265
+ if (parsed.totals && parsed.daily) {
266
+ ccusageData = parsed;
267
+ try {
268
+ writeFileSync(ccusageCachePath, JSON.stringify({ ...parsed, _ts: Date.now() }));
269
+ } catch {
270
+ /* non-critical */
271
+ }
272
+ }
273
+ } catch {
274
+ // ccusage not installed or timed out
275
+ }
553
276
  }
554
- }
555
277
 
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
- });
278
+ // Claude Code Insights report — read raw HTML, pipeline parses it
279
+ let insightsReportHtml = null;
280
+ const reportPath = join(CLAUDE_DIR, "usage-data", "report.html");
281
+ if (existsSync(reportPath)) {
282
+ try {
283
+ insightsReportHtml = readFileSync(reportPath, "utf8");
284
+ } catch {
285
+ // skip if unreadable
286
+ }
600
287
  }
601
- }
602
288
 
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);
289
+ // Stats cache
290
+ const statsCachePath = join(CLAUDE_DIR, "stats-cache.json");
291
+ let statsCache = {};
292
+ if (existsSync(statsCachePath)) {
293
+ try {
294
+ statsCache = JSON.parse(readFileSync(statsCachePath, "utf8"));
295
+ } catch {
296
+ // skip if parse fails
297
+ }
631
298
  }
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
299
 
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
- }
300
+ // Scan scope
301
+ const scanScope = existsSync(CONF) ? `config: ${shortPath(CONF)}` : "~/ (depth 5)";
661
302
 
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
- });
303
+ return {
304
+ repos,
305
+ globalCmds,
306
+ globalRules,
307
+ globalSkills,
308
+ userMcpServers,
309
+ projectMcpByRepo,
310
+ disabledMcpByRepo,
311
+ historicalMcpMap,
312
+ sessionMetaFiles,
313
+ ccusageData,
314
+ statsCache,
315
+ insightsReportHtml,
316
+ chains,
317
+ scanScope,
318
+ insightsReportPath: reportPath,
319
+ };
670
320
  }
671
321
 
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();
322
+ // ── Build Dashboard Data ─────────────────────────────────────────────────────
679
323
 
680
- const scanScope = existsSync(CONF) ? `config: ${shortPath(CONF)}` : "~/ (depth 5)";
324
+ const rawInputs = collectRawInputs();
325
+ const data = buildDashboardData(rawInputs);
681
326
 
682
327
  // ── Lint Subcommand ──────────────────────────────────────────────────────────
683
328
 
684
329
  if (cliArgs.command === "lint") {
685
330
  let totalIssues = 0;
686
- for (const repo of configured) {
331
+ for (const repo of data.configured) {
687
332
  const issues = lintConfig(repo);
688
333
  if (issues.length === 0) continue;
689
334
  if (!cliArgs.quiet) console.log(`\n${repo.name} (${repo.shortPath}):`);
@@ -705,7 +350,10 @@ if (cliArgs.command === "lint") {
705
350
  const SNAPSHOT_PATH = join(CLAUDE_DIR, "dashboard-snapshot.json");
706
351
  if (cliArgs.diff) {
707
352
  const currentSnapshot = {
708
- configuredRepos: configured.map((r) => ({ name: r.name, healthScore: r.healthScore || 0 })),
353
+ configuredRepos: data.configured.map((r) => ({
354
+ name: r.name,
355
+ healthScore: r.healthScore || 0,
356
+ })),
709
357
  };
710
358
  if (existsSync(SNAPSHOT_PATH)) {
711
359
  try {
@@ -732,59 +380,60 @@ if (cliArgs.diff) {
732
380
 
733
381
  if (cliArgs.anonymize) {
734
382
  anonymizeAll({
735
- configured,
736
- unconfigured,
737
- globalCmds,
738
- globalRules,
739
- globalSkills,
740
- chains,
741
- mcpSummary,
742
- mcpPromotions,
743
- formerMcpServers,
744
- consolidationGroups,
383
+ configured: data.configured,
384
+ unconfigured: data.unconfigured,
385
+ globalCmds: data.globalCmds,
386
+ globalRules: data.globalRules,
387
+ globalSkills: data.globalSkills,
388
+ chains: data.chains,
389
+ mcpSummary: data.mcpSummary,
390
+ mcpPromotions: data.mcpPromotions,
391
+ formerMcpServers: data.formerMcpServers,
392
+ consolidationGroups: data.consolidationGroups,
745
393
  });
746
394
  }
747
395
 
748
396
  // ── JSON Output ──────────────────────────────────────────────────────────────
749
397
 
750
398
  if (cliArgs.json) {
399
+ const now = new Date();
751
400
  const jsonData = {
752
401
  version: VERSION,
753
402
  generatedAt: now.toISOString(),
754
- scanScope,
403
+ scanScope: data.scanScope,
755
404
  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
405
+ totalRepos: data.totalRepos,
406
+ configuredRepos: data.configuredCount,
407
+ unconfiguredRepos: data.unconfiguredCount,
408
+ coveragePct: data.coveragePct,
409
+ globalCommands: data.globalCmds.length,
410
+ globalRules: data.globalRules.length,
411
+ skills: data.globalSkills.length,
412
+ repoCommands: data.totalRepoCmds,
413
+ avgHealthScore: data.avgHealth,
414
+ driftingRepos: data.driftCount,
415
+ mcpServers: data.mcpCount,
416
+ ...(data.ccusageData
768
417
  ? {
769
- totalCost: ccusageData.totals.totalCost,
770
- totalTokens: ccusageData.totals.totalTokens,
418
+ totalCost: data.ccusageData.totals.totalCost,
419
+ totalTokens: data.ccusageData.totals.totalTokens,
771
420
  }
772
421
  : {}),
773
- errorCategories: usageAnalytics.errorCategories,
422
+ errorCategories: data.usageAnalytics.errorCategories,
774
423
  },
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) => ({
424
+ globalCommands: data.globalCmds.map((c) => ({ name: c.name, description: c.desc })),
425
+ globalRules: data.globalRules.map((r) => ({ name: r.name, description: r.desc })),
426
+ skills: data.globalSkills.map((s) => ({
778
427
  name: s.name,
779
428
  description: s.desc,
780
429
  source: s.source,
781
430
  category: s.category,
782
431
  })),
783
- chains: chains.map((c) => ({
432
+ chains: data.chains.map((c) => ({
784
433
  nodes: c.nodes.map((n) => n.trim()),
785
434
  direction: c.arrow === "&rarr;" ? "forward" : "backward",
786
435
  })),
787
- configuredRepos: configured.map((r) => ({
436
+ configuredRepos: data.configured.map((r) => ({
788
437
  name: r.name,
789
438
  path: r.shortPath,
790
439
  commands: r.commands.map((c) => ({ name: c.name, description: c.desc })),
@@ -805,8 +454,8 @@ if (cliArgs.json) {
805
454
  similarRepos: r.similarRepos || [],
806
455
  mcpServers: r.mcpServers || [],
807
456
  })),
808
- consolidationGroups,
809
- unconfiguredRepos: unconfigured.map((r) => ({
457
+ consolidationGroups: data.consolidationGroups,
458
+ unconfiguredRepos: data.unconfigured.map((r) => ({
810
459
  name: r.name,
811
460
  path: r.shortPath,
812
461
  techStack: r.techStack || [],
@@ -814,9 +463,9 @@ if (cliArgs.json) {
814
463
  exemplar: r.exemplarName || "",
815
464
  mcpServers: r.mcpServers || [],
816
465
  })),
817
- mcpServers: mcpSummary,
818
- mcpPromotions,
819
- formerMcpServers,
466
+ mcpServers: data.mcpSummary,
467
+ mcpPromotions: data.mcpPromotions,
468
+ formerMcpServers: data.formerMcpServers,
820
469
  };
821
470
 
822
471
  const jsonOutput = JSON.stringify(jsonData, null, 2);
@@ -834,8 +483,8 @@ if (cliArgs.json) {
834
483
  // ── Catalog Output ───────────────────────────────────────────────────────────
835
484
 
836
485
  if (cliArgs.catalog) {
837
- const groups = groupSkillsByCategory(globalSkills);
838
- const catalogHtml = generateCatalogHtml(groups, globalSkills.length, timestamp);
486
+ const groups = groupSkillsByCategory(data.globalSkills);
487
+ const catalogHtml = generateCatalogHtml(groups, data.globalSkills.length, data.timestamp);
839
488
  const outputPath =
840
489
  cliArgs.output !== DEFAULT_OUTPUT ? cliArgs.output : join(CLAUDE_DIR, "skill-catalog.html");
841
490
  mkdirSync(dirname(outputPath), { recursive: true });
@@ -851,33 +500,7 @@ if (cliArgs.catalog) {
851
500
 
852
501
  // ── Generate HTML Dashboard ──────────────────────────────────────────────────
853
502
 
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
- });
503
+ const html = generateDashboardHtml(data);
881
504
 
882
505
  // ── Write HTML Output ────────────────────────────────────────────────────────
883
506
 
@@ -889,10 +512,10 @@ if (!cliArgs.quiet) {
889
512
  const sp = shortPath(outputPath);
890
513
  console.log(`\n claude-code-dashboard v${VERSION}\n`);
891
514
  console.log(
892
- ` ${configuredCount} configured · ${unconfiguredCount} unconfigured · ${totalRepos} repos`,
515
+ ` ${data.configuredCount} configured · ${data.unconfiguredCount} unconfigured · ${data.totalRepos} repos`,
893
516
  );
894
517
  console.log(
895
- ` ${globalCmds.length} global commands · ${globalSkills.length} skills · ${mcpCount} MCP servers`,
518
+ ` ${data.globalCmds.length} global commands · ${data.globalSkills.length} skills · ${data.mcpCount} MCP servers`,
896
519
  );
897
520
  console.log(`\n ✓ ${sp}`);
898
521
  if (cliArgs.open) console.log(` ✓ opening in browser`);
@@ -909,5 +532,5 @@ if (cliArgs.open) {
909
532
  // ── Watch Mode ───────────────────────────────────────────────────────────────
910
533
 
911
534
  if (cliArgs.watch) {
912
- startWatch(outputPath, scanRoots, cliArgs);
535
+ startWatch(outputPath, getScanRoots(), cliArgs);
913
536
  }