@viren/claude-code-dashboard 0.0.2 → 0.0.3

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 CHANGED
@@ -33,7 +33,7 @@ Scans your home directory for git repos, collects Claude Code configuration (com
33
33
 
34
34
  ### Dark mode
35
35
 
36
- ![Dark Mode](screenshots/06-light-mode.png)
36
+ ![Dark Mode](screenshots/06-dark-mode.png)
37
37
 
38
38
  > Screenshots generated with `claude-code-dashboard --demo`
39
39
 
@@ -20,7 +20,16 @@ import { execFileSync, execFile } from "child_process";
20
20
  import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from "fs";
21
21
  import { join, basename, dirname } from "path";
22
22
 
23
- import { VERSION, HOME, CLAUDE_DIR, DEFAULT_OUTPUT, CONF, MAX_DEPTH } from "./src/constants.mjs";
23
+ import {
24
+ VERSION,
25
+ HOME,
26
+ CLAUDE_DIR,
27
+ DEFAULT_OUTPUT,
28
+ CONF,
29
+ MAX_DEPTH,
30
+ REPO_URL,
31
+ SIMILARITY_THRESHOLD,
32
+ } from "./src/constants.mjs";
24
33
  import { parseArgs, generateCompletions } from "./src/cli.mjs";
25
34
  import { shortPath } from "./src/helpers.mjs";
26
35
  import { anonymizeAll } from "./src/anonymize.mjs";
@@ -46,6 +55,7 @@ import {
46
55
  parseProjectMcpConfig,
47
56
  findPromotionCandidates,
48
57
  scanHistoricalMcpServers,
58
+ classifyHistoricalServers,
49
59
  } from "./src/mcp.mjs";
50
60
  import { aggregateSessionMeta } from "./src/usage.mjs";
51
61
  import { handleInit } from "./src/templates.mjs";
@@ -69,7 +79,15 @@ if (cliArgs.demo) {
69
79
  const outputPath = cliArgs.output;
70
80
  mkdirSync(dirname(outputPath), { recursive: true });
71
81
  writeFileSync(outputPath, html);
72
- if (!cliArgs.quiet) console.log(outputPath);
82
+
83
+ if (!cliArgs.quiet) {
84
+ const sp = shortPath(outputPath);
85
+ console.log(`\n claude-code-dashboard v${VERSION} (demo mode)\n`);
86
+ console.log(` ✓ ${sp}`);
87
+ if (cliArgs.open) console.log(` ✓ opening in browser`);
88
+ console.log(`\n ${REPO_URL}`);
89
+ console.log();
90
+ }
73
91
 
74
92
  if (cliArgs.open) {
75
93
  const cmd =
@@ -185,7 +203,7 @@ for (const repo of configured) {
185
203
  const similar = configured
186
204
  .filter((r) => r !== repo)
187
205
  .map((r) => ({ name: r.name, similarity: computeConfigSimilarity(repo, r) }))
188
- .filter((r) => r.similarity >= 40)
206
+ .filter((r) => r.similarity >= SIMILARITY_THRESHOLD)
189
207
  .sort((a, b) => b.similarity - a.similarity)
190
208
  .slice(0, 2);
191
209
  repo.similarRepos = similar;
@@ -317,19 +335,41 @@ for (const s of allMcpServers) {
317
335
  for (const entry of Object.values(mcpByName)) {
318
336
  entry.disabledIn = disabledByServer[entry.name] || 0;
319
337
  }
338
+
339
+ const historicalMcpMap = scanHistoricalMcpServers(CLAUDE_DIR);
340
+ const currentMcpNames = new Set(allMcpServers.map((s) => s.name));
341
+ const { recent: recentMcpServers, former: formerMcpServers } = classifyHistoricalServers(
342
+ historicalMcpMap,
343
+ currentMcpNames,
344
+ );
345
+
346
+ // Normalize all historical project paths
347
+ for (const server of [...recentMcpServers, ...formerMcpServers]) {
348
+ server.projects = server.projects.map((p) => shortPath(p));
349
+ }
350
+
351
+ // Merge recently-seen servers into allMcpServers so they show up as current
352
+ for (const server of recentMcpServers) {
353
+ if (!mcpByName[server.name]) {
354
+ mcpByName[server.name] = {
355
+ name: server.name,
356
+ type: "unknown",
357
+ projects: server.projects,
358
+ userLevel: false,
359
+ disabledIn: disabledByServer[server.name] || 0,
360
+ recentlyActive: true,
361
+ };
362
+ }
363
+ }
320
364
  const mcpSummary = Object.values(mcpByName).sort((a, b) => {
321
365
  if (a.userLevel !== b.userLevel) return a.userLevel ? -1 : 1;
322
366
  return a.name.localeCompare(b.name);
323
367
  });
324
368
  const mcpCount = mcpSummary.length;
325
369
 
326
- const historicalMcpNames = scanHistoricalMcpServers(CLAUDE_DIR);
327
- const currentMcpNames = new Set(allMcpServers.map((s) => s.name));
328
- const formerMcpServers = historicalMcpNames.filter((name) => !currentMcpNames.has(name)).sort();
329
-
330
370
  // ── Usage Analytics ──────────────────────────────────────────────────────────
331
371
 
332
- const SESSION_META_LIMIT = 500;
372
+ const SESSION_META_LIMIT = 1000;
333
373
  const sessionMetaDir = join(CLAUDE_DIR, "usage-data", "session-meta");
334
374
  const sessionMetaFiles = [];
335
375
  if (existsSync(sessionMetaDir)) {
@@ -651,7 +691,21 @@ const html = generateDashboardHtml({
651
691
  const outputPath = cliArgs.output;
652
692
  mkdirSync(dirname(outputPath), { recursive: true });
653
693
  writeFileSync(outputPath, html);
654
- if (!cliArgs.quiet) console.log(outputPath);
694
+
695
+ if (!cliArgs.quiet) {
696
+ const sp = shortPath(outputPath);
697
+ console.log(`\n claude-code-dashboard v${VERSION}\n`);
698
+ console.log(
699
+ ` ${configuredCount} configured · ${unconfiguredCount} unconfigured · ${totalRepos} repos`,
700
+ );
701
+ console.log(
702
+ ` ${globalCmds.length} global commands · ${globalSkills.length} skills · ${mcpCount} MCP servers`,
703
+ );
704
+ console.log(`\n ✓ ${sp}`);
705
+ if (cliArgs.open) console.log(` ✓ opening in browser`);
706
+ console.log(`\n ${REPO_URL}`);
707
+ console.log();
708
+ }
655
709
 
656
710
  if (cliArgs.open) {
657
711
  const cmd =
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viren/claude-code-dashboard",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "A visual dashboard for your Claude Code configuration across all repos",
5
5
  "type": "module",
6
6
  "bin": {
package/src/anonymize.mjs CHANGED
@@ -205,9 +205,13 @@ export function anonymizeAll({
205
205
  });
206
206
  }
207
207
 
208
- // Former MCP servers — anonymize names
208
+ // Former MCP servers — anonymize names and projects
209
209
  for (let i = 0; i < formerMcpServers.length; i++) {
210
- formerMcpServers[i] = `former-server-${i + 1}`;
210
+ formerMcpServers[i] = {
211
+ name: `former-server-${i + 1}`,
212
+ projects: (formerMcpServers[i].projects || []).map(() => `~/project-${i + 1}`),
213
+ lastSeen: formerMcpServers[i].lastSeen,
214
+ };
211
215
  }
212
216
 
213
217
  // Consolidation groups
package/src/cli.mjs CHANGED
@@ -3,7 +3,7 @@ import { VERSION, DEFAULT_OUTPUT, HOME } from "./constants.mjs";
3
3
  export function parseArgs(argv) {
4
4
  const args = {
5
5
  output: DEFAULT_OUTPUT,
6
- open: false,
6
+ open: process.stdout.isTTY !== false,
7
7
  json: false,
8
8
  catalog: false,
9
9
  command: null,
@@ -40,7 +40,8 @@ Options:
40
40
  --output, -o <path> Output path (default: ~/.claude/dashboard.html)
41
41
  --json Output full data model as JSON instead of HTML
42
42
  --catalog Generate a shareable skill catalog HTML page
43
- --open Open the dashboard in your default browser after generating
43
+ --open Open in browser after generating (default: true)
44
+ --no-open Skip opening in browser
44
45
  --quiet Suppress output, just write file
45
46
  --watch Regenerate on file changes
46
47
  --diff Show changes since last generation
@@ -86,6 +87,9 @@ Config file: ~/.claude/dashboard.conf
86
87
  case "--open":
87
88
  args.open = true;
88
89
  break;
90
+ case "--no-open":
91
+ args.open = false;
92
+ break;
89
93
  case "--template":
90
94
  case "-t":
91
95
  args.template = argv[++i];
@@ -129,11 +133,11 @@ export function generateCompletions() {
129
133
  # eval "$(claude-code-dashboard --completions)"
130
134
  if [ -n "$ZSH_VERSION" ]; then
131
135
  _claude_code_dashboard() {
132
- local -a opts; opts=(init lint --output --open --json --catalog --quiet --watch --diff --anonymize --demo --completions --help --version)
136
+ local -a opts; opts=(init lint --output --open --no-open --json --catalog --quiet --watch --diff --anonymize --demo --completions --help --version)
133
137
  if (( CURRENT == 2 )); then _describe 'option' opts; fi
134
138
  }; compdef _claude_code_dashboard claude-code-dashboard
135
139
  elif [ -n "$BASH_VERSION" ]; then
136
- _claude_code_dashboard() { COMPREPLY=( $(compgen -W "init lint --output --open --json --catalog --quiet --watch --diff --anonymize --demo --completions --help --version" -- "\${COMP_WORDS[COMP_CWORD]}") ); }
140
+ _claude_code_dashboard() { COMPREPLY=( $(compgen -W "init lint --output --open --no-open --json --catalog --quiet --watch --diff --anonymize --demo --completions --help --version" -- "\${COMP_WORDS[COMP_CWORD]}") ); }
137
141
  complete -F _claude_code_dashboard claude-code-dashboard
138
142
  fi`);
139
143
  process.exit(0);
package/src/constants.mjs CHANGED
@@ -1,13 +1,16 @@
1
1
  import { join } from "path";
2
2
  import { homedir } from "os";
3
3
 
4
- export const VERSION = "0.0.2";
4
+ export const VERSION = "0.0.3";
5
+ export const REPO_URL = "https://github.com/VirenMohindra/claude-code-dashboard";
5
6
 
6
7
  export const HOME = homedir();
7
8
  export const CLAUDE_DIR = join(HOME, ".claude");
8
9
  export const DEFAULT_OUTPUT = join(CLAUDE_DIR, "dashboard.html");
9
10
  export const CONF = join(CLAUDE_DIR, "dashboard.conf");
10
11
  export const MAX_DEPTH = 5;
12
+ export const MAX_SESSION_SCAN = 1000;
13
+ export const SIMILARITY_THRESHOLD = 25;
11
14
 
12
15
  // Freshness thresholds (seconds)
13
16
  export const ONE_DAY = 86_400;
package/src/demo.mjs CHANGED
@@ -407,9 +407,20 @@ export function generateDemoData() {
407
407
  disabledIn: 0,
408
408
  },
409
409
  { name: "sentry", type: "http", projects: [], userLevel: true, disabledIn: 0 },
410
+ {
411
+ name: "figma",
412
+ type: "stdio",
413
+ projects: ["~/work/acme-web"],
414
+ userLevel: false,
415
+ disabledIn: 0,
416
+ recentlyActive: true,
417
+ },
410
418
  ],
411
419
  mcpPromotions: [{ name: "github", projects: ["~/work/acme-web", "~/work/payments-api"] }],
412
- formerMcpServers: ["redis", "datadog"],
420
+ formerMcpServers: [
421
+ { name: "redis", projects: ["~/work/cache-service"], lastSeen: null },
422
+ { name: "datadog", projects: [], lastSeen: null },
423
+ ],
413
424
  consolidationGroups: [
414
425
  {
415
426
  stack: "next",
@@ -1,5 +1,5 @@
1
1
  import { esc, formatTokens } from "./helpers.mjs";
2
- import { QUICK_REFERENCE } from "./constants.mjs";
2
+ import { QUICK_REFERENCE, VERSION, REPO_URL } from "./constants.mjs";
3
3
  import {
4
4
  renderCmd,
5
5
  renderRule,
@@ -34,6 +34,335 @@ export function generateDashboardHtml({
34
34
  mcpCount,
35
35
  scanScope,
36
36
  }) {
37
+ // ── Build tab content sections ──────────────────────────────────────────
38
+
39
+ // Skills card
40
+ const skillsHtml = globalSkills.length
41
+ ? (() => {
42
+ const groups = groupSkillsByCategory(globalSkills);
43
+ const categoryHtml = Object.entries(groups)
44
+ .map(
45
+ ([cat, skills], idx) =>
46
+ `<details class="skill-category"${idx === 0 ? " open" : ""}>` +
47
+ `<summary class="skill-category-label">${esc(cat)} <span class="cat-n">${skills.length}</span></summary>` +
48
+ skills.map((s) => renderSkill(s)).join("\n ") +
49
+ `</details>`,
50
+ )
51
+ .join("\n ");
52
+ return `<div class="card">
53
+ <h2>Skills <span class="n">${globalSkills.length}</span></h2>
54
+ ${categoryHtml}
55
+ </div>`;
56
+ })()
57
+ : "";
58
+
59
+ // MCP card
60
+ const mcpHtml = mcpSummary.length
61
+ ? (() => {
62
+ const rows = mcpSummary
63
+ .map((s) => {
64
+ const disabledClass = s.disabledIn > 0 ? " mcp-disabled" : "";
65
+ const disabledHint =
66
+ s.disabledIn > 0
67
+ ? `<span class="mcp-disabled-hint">disabled in ${s.disabledIn} project${s.disabledIn > 1 ? "s" : ""}</span>`
68
+ : "";
69
+ const scopeBadge = s.userLevel
70
+ ? `<span class="badge mcp-global">global</span>`
71
+ : s.recentlyActive
72
+ ? `<span class="badge mcp-recent">recent</span>`
73
+ : `<span class="badge mcp-project">project</span>`;
74
+ const typeBadge = `<span class="badge mcp-type">${esc(s.type)}</span>`;
75
+ const projects = s.projects.length
76
+ ? `<span class="mcp-projects">${s.projects.map((p) => esc(p)).join(", ")}</span>`
77
+ : "";
78
+ return `<div class="mcp-row${disabledClass}"><span class="mcp-name">${esc(s.name)}</span>${scopeBadge}${typeBadge}${disabledHint}${projects}</div>`;
79
+ })
80
+ .join("\n ");
81
+ const promoteHtml = mcpPromotions.length
82
+ ? mcpPromotions
83
+ .map(
84
+ (p) =>
85
+ `<div class="mcp-promote"><span class="mcp-name">${esc(p.name)}</span> installed in ${p.projects.length} projects &rarr; add to <code>~/.claude/mcp_config.json</code></div>`,
86
+ )
87
+ .join("\n ")
88
+ : "";
89
+ const formerHtml = formerMcpServers.length
90
+ ? `<div class="label" style="margin-top:.75rem">Formerly Installed</div>
91
+ ${formerMcpServers
92
+ .map((s) => {
93
+ const hint = s.projects.length
94
+ ? `<span class="mcp-projects">${s.projects.map((p) => esc(p)).join(", ")}</span>`
95
+ : "";
96
+ return `<div class="mcp-row mcp-former"><span class="mcp-name">${esc(s.name)}</span><span class="badge mcp-former-badge">removed</span>${hint}</div>`;
97
+ })
98
+ .join("\n ")}`
99
+ : "";
100
+ return `<div class="card">
101
+ <h2>MCP Servers <span class="n">${mcpSummary.length}</span></h2>
102
+ ${rows}
103
+ ${promoteHtml}
104
+ ${formerHtml}
105
+ </div>`;
106
+ })()
107
+ : "";
108
+
109
+ // Usage bar cards (tools, languages, errors)
110
+ const toolsHtml = usageAnalytics.topTools.length
111
+ ? (() => {
112
+ const maxCount = usageAnalytics.topTools[0].count;
113
+ const rows = usageAnalytics.topTools
114
+ .map((t) => {
115
+ const pct = maxCount > 0 ? Math.round((t.count / maxCount) * 100) : 0;
116
+ return `<div class="usage-bar-row"><span class="usage-bar-label">${esc(t.name)}</span><div class="usage-bar-track"><div class="usage-bar-fill usage-bar-tool" style="width:${pct}%"></div></div><span class="usage-bar-count">${t.count.toLocaleString()}</span></div>`;
117
+ })
118
+ .join("\n ");
119
+ return `<div class="card">
120
+ <h2>Top Tools Used <span class="n">${usageAnalytics.topTools.length}</span></h2>
121
+ ${rows}
122
+ </div>`;
123
+ })()
124
+ : "";
125
+
126
+ const langsHtml = usageAnalytics.topLanguages.length
127
+ ? (() => {
128
+ const maxCount = usageAnalytics.topLanguages[0].count;
129
+ const rows = usageAnalytics.topLanguages
130
+ .map((l) => {
131
+ const pct = maxCount > 0 ? Math.round((l.count / maxCount) * 100) : 0;
132
+ return `<div class="usage-bar-row"><span class="usage-bar-label">${esc(l.name)}</span><div class="usage-bar-track"><div class="usage-bar-fill usage-bar-lang" style="width:${pct}%"></div></div><span class="usage-bar-count">${l.count.toLocaleString()}</span></div>`;
133
+ })
134
+ .join("\n ");
135
+ return `<div class="card">
136
+ <h2>Languages <span class="n">${usageAnalytics.topLanguages.length}</span></h2>
137
+ ${rows}
138
+ </div>`;
139
+ })()
140
+ : "";
141
+
142
+ const errorsHtml = usageAnalytics.errorCategories.length
143
+ ? (() => {
144
+ const maxCount = usageAnalytics.errorCategories[0].count;
145
+ const rows = usageAnalytics.errorCategories
146
+ .map((e) => {
147
+ const pct = maxCount > 0 ? Math.round((e.count / maxCount) * 100) : 0;
148
+ return `<div class="usage-bar-row"><span class="usage-bar-label">${esc(e.name)}</span><div class="usage-bar-track"><div class="usage-bar-fill usage-bar-error" style="width:${pct}%"></div></div><span class="usage-bar-count">${e.count.toLocaleString()}</span></div>`;
149
+ })
150
+ .join("\n ");
151
+ return `<div class="card">
152
+ <h2>Top Errors <span class="n">${usageAnalytics.errorCategories.length}</span></h2>
153
+ ${rows}
154
+ </div>`;
155
+ })()
156
+ : "";
157
+
158
+ // Activity/heatmap/peak hours/model usage card
159
+ const activityHtml = (() => {
160
+ const dailyActivity = statsCache.dailyActivity || [];
161
+ const hourCounts = statsCache.hourCounts || {};
162
+ const modelUsage = statsCache.modelUsage || {};
163
+ const hasActivity = dailyActivity.length > 0;
164
+ const hasHours = Object.keys(hourCounts).length > 0;
165
+ const hasModels = Object.keys(modelUsage).length > 0;
166
+
167
+ if (!hasActivity && !hasHours && !hasModels && !ccusageData) return "";
168
+
169
+ let content = "";
170
+
171
+ if (hasActivity) {
172
+ const dateMap = new Map(dailyActivity.map((d) => [d.date, d.messageCount || 0]));
173
+ const dates = dailyActivity.map((d) => d.date).sort();
174
+ const lastDate = new Date(dates[dates.length - 1]);
175
+ const firstDate = new Date(lastDate);
176
+ firstDate.setDate(firstDate.getDate() - 364);
177
+
178
+ const nonZero = dailyActivity
179
+ .map((d) => d.messageCount || 0)
180
+ .filter((n) => n > 0)
181
+ .sort((a, b) => a - b);
182
+ const q1 = nonZero[Math.floor(nonZero.length * 0.25)] || 1;
183
+ const q2 = nonZero[Math.floor(nonZero.length * 0.5)] || 2;
184
+ const q3 = nonZero[Math.floor(nonZero.length * 0.75)] || 3;
185
+
186
+ function level(count) {
187
+ if (count === 0) return "";
188
+ if (count <= q1) return " l1";
189
+ if (count <= q2) return " l2";
190
+ if (count <= q3) return " l3";
191
+ return " l4";
192
+ }
193
+
194
+ const start = new Date(firstDate);
195
+ start.setUTCDate(start.getUTCDate() - start.getUTCDay());
196
+
197
+ const months = [];
198
+ let lastMonth = -1;
199
+ const cursor1 = new Date(start);
200
+ let weekIdx = 0;
201
+ while (cursor1 <= lastDate) {
202
+ if (cursor1.getUTCDay() === 0) {
203
+ const m = cursor1.getUTCMonth();
204
+ if (m !== lastMonth) {
205
+ months.push({
206
+ name: cursor1.toLocaleString("en", { month: "short", timeZone: "UTC" }),
207
+ week: weekIdx,
208
+ });
209
+ lastMonth = m;
210
+ }
211
+ weekIdx++;
212
+ }
213
+ cursor1.setUTCDate(cursor1.getUTCDate() + 1);
214
+ }
215
+ const totalWeeks = weekIdx;
216
+ const monthLabels = months
217
+ .map((m) => {
218
+ const left = totalWeeks > 0 ? Math.round((m.week / totalWeeks) * 100) : 0;
219
+ return `<span class="heatmap-month" style="position:absolute;left:${left}%">${m.name}</span>`;
220
+ })
221
+ .join("");
222
+
223
+ let cells = "";
224
+ const cursor2 = new Date(start);
225
+ while (cursor2 <= lastDate) {
226
+ const key = cursor2.toISOString().slice(0, 10);
227
+ const count = dateMap.get(key) || 0;
228
+ cells += `<div class="heatmap-cell${level(count)}" title="${esc(key)}: ${count} messages"></div>`;
229
+ cursor2.setUTCDate(cursor2.getUTCDate() + 1);
230
+ }
231
+
232
+ content += `<div class="label">Activity</div>
233
+ <div style="position:relative;margin-bottom:.5rem">
234
+ <div class="heatmap-months" style="position:relative;height:.8rem">${monthLabels}</div>
235
+ <div style="overflow-x:auto"><div class="heatmap">${cells}</div></div>
236
+ </div>`;
237
+ }
238
+
239
+ if (hasHours) {
240
+ const maxHour = Math.max(...Object.values(hourCounts), 1);
241
+ let bars = "";
242
+ let labels = "";
243
+ for (let h = 0; h < 24; h++) {
244
+ const count = hourCounts[String(h)] || 0;
245
+ const pct = Math.round((count / maxHour) * 100);
246
+ bars += `<div class="peak-bar" style="height:${Math.max(pct, 2)}%" title="${esc(String(h))}:00 — ${count} messages"></div>`;
247
+ labels += `<div class="peak-label">${h % 6 === 0 ? h : ""}</div>`;
248
+ }
249
+ content += `<div class="label" style="margin-top:.75rem">Peak Hours</div>
250
+ <div class="peak-hours">${bars}</div>
251
+ <div class="peak-labels">${labels}</div>`;
252
+ }
253
+
254
+ if (ccusageData) {
255
+ const modelCosts = {};
256
+ for (const day of ccusageData.daily) {
257
+ for (const mb of day.modelBreakdowns || []) {
258
+ if (!modelCosts[mb.modelName]) modelCosts[mb.modelName] = { cost: 0, tokens: 0 };
259
+ modelCosts[mb.modelName].cost += mb.cost || 0;
260
+ modelCosts[mb.modelName].tokens +=
261
+ (mb.inputTokens || 0) +
262
+ (mb.outputTokens || 0) +
263
+ (mb.cacheCreationTokens || 0) +
264
+ (mb.cacheReadTokens || 0);
265
+ }
266
+ }
267
+ const modelRows = Object.entries(modelCosts)
268
+ .sort((a, b) => b[1].cost - a[1].cost)
269
+ .map(
270
+ ([name, data]) =>
271
+ `<div class="model-row"><span class="model-name">${esc(name)}</span><span class="model-tokens">$${Math.round(data.cost).toLocaleString()} · ${formatTokens(data.tokens)}</span></div>`,
272
+ )
273
+ .join("\n ");
274
+
275
+ const t = ccusageData.totals;
276
+ const breakdownHtml = `<div class="token-breakdown">
277
+ <div class="tb-row"><span class="tb-label">Cache Read</span><span class="tb-val">${formatTokens(t.cacheReadTokens)}</span></div>
278
+ <div class="tb-row"><span class="tb-label">Cache Creation</span><span class="tb-val">${formatTokens(t.cacheCreationTokens)}</span></div>
279
+ <div class="tb-row"><span class="tb-label">Output</span><span class="tb-val">${formatTokens(t.outputTokens)}</span></div>
280
+ <div class="tb-row"><span class="tb-label">Input</span><span class="tb-val">${formatTokens(t.inputTokens)}</span></div>
281
+ </div>`;
282
+
283
+ content += `<div class="label" style="margin-top:.75rem">Model Usage (via ccusage)</div>
284
+ ${modelRows}
285
+ <div class="label" style="margin-top:.75rem">Token Breakdown</div>
286
+ ${breakdownHtml}`;
287
+ } else if (hasModels) {
288
+ const modelRows = Object.entries(modelUsage)
289
+ .map(([name, usage]) => {
290
+ const total = (usage.inputTokens || 0) + (usage.outputTokens || 0);
291
+ return { name, total };
292
+ })
293
+ .sort((a, b) => b.total - a.total)
294
+ .map(
295
+ (m) =>
296
+ `<div class="model-row"><span class="model-name">${esc(m.name)}</span><span class="model-tokens">${formatTokens(m.total)}</span></div>`,
297
+ )
298
+ .join("\n ");
299
+ content += `<div class="label" style="margin-top:.75rem">Model Usage (partial — install ccusage for full data)</div>
300
+ ${modelRows}`;
301
+ }
302
+
303
+ return `<div class="card">
304
+ <h2>Activity</h2>
305
+ ${content}
306
+ </div>`;
307
+ })();
308
+
309
+ // Chains
310
+ const chainsHtml = chains.length
311
+ ? `<div class="card">
312
+ <h2>Dependency Chains</h2>
313
+ ${chains.map((c) => `<div class="chain">${c.nodes.map((n, i) => `<span class="chain-node">${esc(n.trim())}</span>${i < c.nodes.length - 1 ? `<span class="chain-arrow">${c.arrow}</span>` : ""}`).join("")}</div>`).join("\n ")}
314
+ </div>`
315
+ : "";
316
+
317
+ // Consolidation
318
+ const consolidationHtml = consolidationGroups.length
319
+ ? `<div class="card">
320
+ <h2>Consolidation Opportunities <span class="n">${consolidationGroups.length}</span></h2>
321
+ ${consolidationGroups.map((g) => `<div class="consolidation-hint"><span class="consolidation-stack">${esc(g.stack)}</span> <span class="consolidation-text">${esc(g.suggestion)}</span></div>`).join("\n ")}
322
+ </div>`
323
+ : "";
324
+
325
+ // Unconfigured repos
326
+ const unconfiguredHtml = unconfigured.length
327
+ ? `<details class="card">
328
+ <summary style="cursor:pointer;list-style:none"><h2 style="margin:0">Unconfigured Repos <span class="n">${unconfiguredCount}</span></h2></summary>
329
+ <div style="margin-top:.75rem">
330
+ <div class="unconfigured-grid">
331
+ ${unconfigured
332
+ .map((r) => {
333
+ const stackTag =
334
+ r.techStack && r.techStack.length
335
+ ? `<span class="stack-tag">${esc(r.techStack.join(", "))}</span>`
336
+ : "";
337
+ const suggestionsHtml =
338
+ r.suggestions && r.suggestions.length
339
+ ? `<div class="suggestion-hints">${r.suggestions.map((s) => `<span class="suggestion-hint">${esc(s)}</span>`).join("")}</div>`
340
+ : "";
341
+ return `<div class="unconfigured-item">${esc(r.name)}${stackTag}<span class="upath">${esc(r.shortPath)}</span>${suggestionsHtml}</div>`;
342
+ })
343
+ .join("\n ")}
344
+ </div>
345
+ </div>
346
+ </details>`
347
+ : "";
348
+
349
+ // Quick reference
350
+ const referenceHtml = `<div class="card">
351
+ <h2>Quick Reference</h2>
352
+ <div class="ref-grid">
353
+ <div class="ref-col">
354
+ <div class="label">Essential Commands</div>
355
+ ${QUICK_REFERENCE.essentialCommands.map((c) => `<div class="ref-row"><code class="ref-cmd">${esc(c.cmd)}</code><span class="ref-desc">${esc(c.desc)}</span></div>`).join("\n ")}
356
+ </div>
357
+ <div class="ref-col">
358
+ <div class="label">Built-in Tools</div>
359
+ ${QUICK_REFERENCE.tools.map((t) => `<div class="ref-row"><code class="ref-cmd">${esc(t.name)}</code><span class="ref-desc">${esc(t.desc)}</span></div>`).join("\n ")}
360
+ <div class="label" style="margin-top:.75rem">Keyboard Shortcuts</div>
361
+ ${QUICK_REFERENCE.shortcuts.map((s) => `<div class="ref-row"><kbd class="ref-key">${esc(s.keys)}</kbd><span class="ref-desc">${esc(s.desc)}</span></div>`).join("\n ")}
362
+ </div>
363
+ </div>
364
+ </div>`;
365
+
37
366
  return `<!DOCTYPE html>
38
367
  <html lang="en">
39
368
  <head>
@@ -61,14 +390,28 @@ export function generateDashboardHtml({
61
390
  }
62
391
  code, .cmd-name { font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace; }
63
392
  h1 { font-size: 1.4rem; font-weight: 600; color: var(--accent); margin-bottom: .2rem; }
64
- .sub { color: var(--text-dim); font-size: .78rem; margin-bottom: 1.5rem; }
393
+ .sub { color: var(--text-dim); font-size: .78rem; margin-bottom: 1rem; }
65
394
  kbd { background: var(--surface2); border: 1px solid var(--border); border-radius: 3px; padding: .05rem .3rem; font-size: .7rem; font-family: inherit; }
66
395
 
396
+ /* ── Tabs ─────────────────────────────────────────────────── */
397
+ .tab-nav { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 1.5rem; overflow-x: auto; }
398
+ .tab-btn {
399
+ padding: .6rem 1.2rem; font-size: .78rem; font-weight: 500; color: var(--text-dim);
400
+ background: none; border: none; border-bottom: 2px solid transparent;
401
+ cursor: pointer; white-space: nowrap; font-family: inherit; transition: color .15s, border-color .15s;
402
+ }
403
+ .tab-btn:hover { color: var(--text); }
404
+ .tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
405
+ .tab-content { display: none; }
406
+ .tab-content.active { display: block; }
407
+
408
+ /* ── Cards ────────────────────────────────────────────────── */
67
409
  .top-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; margin-bottom: 1.25rem; }
410
+ .top-grid > .card { margin-bottom: 0; }
68
411
  @media (max-width: 900px) { .top-grid { grid-template-columns: 1fr; } }
69
412
 
70
- .card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 1.25rem; overflow: hidden; }
71
- .card.full { grid-column: 1 / -1; }
413
+ .card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 1.25rem; overflow: hidden; margin-bottom: 1.25rem; }
414
+ .card:last-child { margin-bottom: 0; }
72
415
  .card h2 { font-size: .7rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: var(--text-dim); margin-bottom: .75rem; display: flex; align-items: center; gap: .5rem; }
73
416
  .card h2 .n { background: var(--surface2); border: 1px solid var(--border); border-radius: 4px; padding: .05rem .35rem; font-size: .65rem; color: var(--accent); }
74
417
 
@@ -136,6 +479,7 @@ export function generateDashboardHtml({
136
479
  .repo-card > summary {
137
480
  cursor: pointer; list-style: none; padding: .85rem 1rem;
138
481
  display: flex; flex-direction: column; gap: .3rem;
482
+ min-height: 7.5rem; justify-content: center;
139
483
  }
140
484
  .repo-card > summary::-webkit-details-marker { display: none; }
141
485
  .repo-card > summary:hover { background: var(--surface2); }
@@ -175,6 +519,7 @@ export function generateDashboardHtml({
175
519
  .mcp-projects { font-size: .65rem; color: var(--text-dim); margin-left: auto; }
176
520
  .badge.mcp-global { color: var(--green); border-color: #4ade8033; background: #4ade8010; }
177
521
  .badge.mcp-project { color: var(--blue); border-color: #60a5fa33; background: #60a5fa10; }
522
+ .badge.mcp-recent { color: var(--yellow); border-color: #fbbf2433; background: #fbbf2410; }
178
523
  .badge.mcp-type { color: var(--text-dim); border-color: var(--border); background: var(--surface2); text-transform: none; font-size: .5rem; }
179
524
  .mcp-promote { font-size: .72rem; color: var(--text-dim); padding: .4rem .5rem; background: rgba(251,191,36,.05); border: 1px solid rgba(251,191,36,.15); border-radius: 6px; margin-top: .3rem; }
180
525
  .mcp-promote .mcp-name { color: var(--yellow); }
@@ -191,8 +536,8 @@ export function generateDashboardHtml({
191
536
  .usage-bar-error { background: linear-gradient(90deg, var(--red), var(--yellow)); }
192
537
  .usage-bar-count { font-size: .65rem; color: var(--text-dim); min-width: 40px; text-align: right; font-variant-numeric: tabular-nums; }
193
538
 
194
- .heatmap { display: grid; grid-template-rows: repeat(7, 1fr); grid-auto-flow: column; grid-auto-columns: 1fr; gap: 2px; }
195
- .heatmap-cell { aspect-ratio: 1; border-radius: 2px; background: var(--surface2); min-width: 6px; min-height: 6px; }
539
+ .heatmap { display: grid; grid-template-rows: repeat(7, 10px); grid-auto-flow: column; grid-auto-columns: 10px; gap: 3px; overflow-x: auto; width: fit-content; max-width: 100%; }
540
+ .heatmap-cell { border-radius: 2px; background: var(--surface2); width: 10px; height: 10px; }
196
541
  .heatmap-cell.l1 { background: #0e4429; }
197
542
  .heatmap-cell.l2 { background: #006d32; }
198
543
  .heatmap-cell.l3 { background: #26a641; }
@@ -231,6 +576,9 @@ export function generateDashboardHtml({
231
576
  .quick-win { font-size: .6rem; padding: .15rem .4rem; border-radius: 3px; background: rgba(251,191,36,.08); border: 1px solid rgba(251,191,36,.2); color: var(--yellow); }
232
577
  .matched-skills { display: flex; flex-wrap: wrap; gap: .3rem; margin-bottom: .5rem; }
233
578
  .matched-skill { font-size: .6rem; padding: .12rem .4rem; border-radius: 3px; background: rgba(251,191,36,.08); border: 1px solid rgba(251,191,36,.2); color: var(--yellow); font-family: 'SF Mono', monospace; }
579
+ .similar-repos { display: flex; flex-wrap: wrap; gap: .3rem; margin-bottom: .5rem; }
580
+ .similar-repo { font-size: .6rem; padding: .12rem .4rem; border-radius: 3px; background: rgba(99,179,237,.08); border: 1px solid rgba(99,179,237,.2); color: var(--blue); font-family: 'SF Mono', monospace; }
581
+ .similar-repo small { opacity: .6; }
234
582
  .consolidation-hint { padding: .45rem .6rem; background: var(--surface2); border-radius: 6px; margin-top: .4rem; display: flex; align-items: baseline; gap: .5rem; }
235
583
  .consolidation-hint:first-child { margin-top: 0; }
236
584
  .consolidation-stack { font-size: .7rem; font-weight: 600; color: var(--accent); white-space: nowrap; }
@@ -296,7 +644,7 @@ export function generateDashboardHtml({
296
644
  <body>
297
645
  <h1>claude code dashboard</h1>
298
646
  <button id="theme-toggle" class="theme-toggle" title="Toggle light/dark mode" aria-label="Toggle theme"><span class="theme-icon"></span></button>
299
- <p class="sub">generated ${timestamp} · run <code>claude-code-dashboard</code> to refresh · click to expand</p>
647
+ <p class="sub">generated ${timestamp} · run <code>claude-code-dashboard</code> to refresh · <a href="${esc(REPO_URL)}" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none">v${esc(VERSION)}</a></p>
300
648
 
301
649
  <div class="stats">
302
650
  <div class="stat coverage"><b>${coveragePct}%</b><span>Coverage (${configuredCount}/${totalRepos})</span></div>
@@ -311,371 +659,88 @@ export function generateDashboardHtml({
311
659
  ${usageAnalytics.heavySessions > 0 ? `<div class="stat"><b>${usageAnalytics.heavySessions}</b><span>Heavy Sessions</span></div>` : ""}
312
660
  </div>
313
661
 
314
- <div class="top-grid">
315
- <div class="card">
316
- <h2>Global Commands <span class="n">${globalCmds.length}</span></h2>
317
- ${globalCmds.map((c) => renderCmd(c)).join("\n ")}
318
- </div>
319
- <div class="card">
320
- <h2>Global Rules <span class="n">${globalRules.length}</span></h2>
321
- ${globalRules.map((r) => renderRule(r)).join("\n ")}
322
- </div>
323
- ${
324
- globalSkills.length
325
- ? (() => {
326
- const groups = groupSkillsByCategory(globalSkills);
327
- const categoryHtml = Object.entries(groups)
328
- .map(
329
- ([cat, skills], idx) =>
330
- `<details class="skill-category"${idx === 0 ? " open" : ""}>` +
331
- `<summary class="skill-category-label">${esc(cat)} <span class="cat-n">${skills.length}</span></summary>` +
332
- skills.map((s) => renderSkill(s)).join("\n ") +
333
- `</details>`,
334
- )
335
- .join("\n ");
336
- return `<div class="card full">
337
- <h2>Skills <span class="n">${globalSkills.length}</span></h2>
338
- ${categoryHtml}
339
- </div>`;
340
- })()
341
- : ""
342
- }
343
- ${
344
- mcpSummary.length
345
- ? (() => {
346
- const rows = mcpSummary
347
- .map((s) => {
348
- const disabledClass = s.disabledIn > 0 ? " mcp-disabled" : "";
349
- const disabledHint =
350
- s.disabledIn > 0
351
- ? `<span class="mcp-disabled-hint">disabled in ${s.disabledIn} project${s.disabledIn > 1 ? "s" : ""}</span>`
352
- : "";
353
- const scopeBadge = s.userLevel
354
- ? `<span class="badge mcp-global">global</span>`
355
- : `<span class="badge mcp-project">project</span>`;
356
- const typeBadge = `<span class="badge mcp-type">${esc(s.type)}</span>`;
357
- const projects = s.projects.length
358
- ? `<span class="mcp-projects">${s.projects.map((p) => esc(p)).join(", ")}</span>`
359
- : "";
360
- return `<div class="mcp-row${disabledClass}"><span class="mcp-name">${esc(s.name)}</span>${scopeBadge}${typeBadge}${disabledHint}${projects}</div>`;
361
- })
362
- .join("\n ");
363
- const promoteHtml = mcpPromotions.length
364
- ? mcpPromotions
365
- .map(
366
- (p) =>
367
- `<div class="mcp-promote"><span class="mcp-name">${esc(p.name)}</span> installed in ${p.projects.length} projects &rarr; add to <code>~/.claude/mcp_config.json</code></div>`,
368
- )
369
- .join("\n ")
370
- : "";
371
- const formerHtml = formerMcpServers.length
372
- ? `<div class="label" style="margin-top:.75rem">Formerly Installed</div>
373
- ${formerMcpServers.map((name) => `<div class="mcp-row mcp-former"><span class="mcp-name">${esc(name)}</span><span class="badge mcp-former-badge">removed</span></div>`).join("\n ")}`
374
- : "";
375
- return `<div class="card full">
376
- <h2>MCP Servers <span class="n">${mcpSummary.length}</span></h2>
377
- ${rows}
378
- ${promoteHtml}
379
- ${formerHtml}
380
- </div>`;
381
- })()
382
- : ""
383
- }
384
- ${
385
- usageAnalytics.topTools.length
386
- ? (() => {
387
- const maxCount = usageAnalytics.topTools[0].count;
388
- const rows = usageAnalytics.topTools
389
- .map((t) => {
390
- const pct = maxCount > 0 ? Math.round((t.count / maxCount) * 100) : 0;
391
- return `<div class="usage-bar-row"><span class="usage-bar-label">${esc(t.name)}</span><div class="usage-bar-track"><div class="usage-bar-fill usage-bar-tool" style="width:${pct}%"></div></div><span class="usage-bar-count">${t.count.toLocaleString()}</span></div>`;
392
- })
393
- .join("\n ");
394
- return `<div class="card">
395
- <h2>Top Tools Used <span class="n">${usageAnalytics.topTools.length}</span></h2>
396
- ${rows}
397
- </div>`;
398
- })()
399
- : ""
400
- }
401
- ${
402
- usageAnalytics.topLanguages.length
403
- ? (() => {
404
- const maxCount = usageAnalytics.topLanguages[0].count;
405
- const rows = usageAnalytics.topLanguages
406
- .map((l) => {
407
- const pct = maxCount > 0 ? Math.round((l.count / maxCount) * 100) : 0;
408
- return `<div class="usage-bar-row"><span class="usage-bar-label">${esc(l.name)}</span><div class="usage-bar-track"><div class="usage-bar-fill usage-bar-lang" style="width:${pct}%"></div></div><span class="usage-bar-count">${l.count.toLocaleString()}</span></div>`;
409
- })
410
- .join("\n ");
411
- return `<div class="card">
412
- <h2>Languages <span class="n">${usageAnalytics.topLanguages.length}</span></h2>
413
- ${rows}
414
- </div>`;
415
- })()
416
- : ""
417
- }
418
- ${
419
- usageAnalytics.errorCategories.length
420
- ? (() => {
421
- const maxCount = usageAnalytics.errorCategories[0].count;
422
- const rows = usageAnalytics.errorCategories
423
- .map((e) => {
424
- const pct = maxCount > 0 ? Math.round((e.count / maxCount) * 100) : 0;
425
- return `<div class="usage-bar-row"><span class="usage-bar-label">${esc(e.name)}</span><div class="usage-bar-track"><div class="usage-bar-fill usage-bar-error" style="width:${pct}%"></div></div><span class="usage-bar-count">${e.count.toLocaleString()}</span></div>`;
426
- })
427
- .join("\n ");
428
- return `<div class="card">
429
- <h2>Top Errors <span class="n">${usageAnalytics.errorCategories.length}</span></h2>
430
- ${rows}
431
- </div>`;
432
- })()
433
- : ""
434
- }
435
- ${(() => {
436
- const dailyActivity = statsCache.dailyActivity || [];
437
- const hourCounts = statsCache.hourCounts || {};
438
- const modelUsage = statsCache.modelUsage || {};
439
- const hasActivity = dailyActivity.length > 0;
440
- const hasHours = Object.keys(hourCounts).length > 0;
441
- const hasModels = Object.keys(modelUsage).length > 0;
442
-
443
- if (!hasActivity && !hasHours && !hasModels && !ccusageData) return "";
444
-
445
- let content = "";
446
-
447
- // Activity heatmap
448
- if (hasActivity) {
449
- const dateMap = new Map(dailyActivity.map((d) => [d.date, d.messageCount || 0]));
450
- const dates = dailyActivity.map((d) => d.date).sort();
451
- const lastDate = new Date(dates[dates.length - 1]);
452
- const earliest = new Date(lastDate);
453
- earliest.setDate(earliest.getDate() - 364);
454
- const firstDate = new Date(dates[0]) < earliest ? earliest : new Date(dates[0]);
455
-
456
- const nonZero = dailyActivity
457
- .map((d) => d.messageCount || 0)
458
- .filter((n) => n > 0)
459
- .sort((a, b) => a - b);
460
- const q1 = nonZero[Math.floor(nonZero.length * 0.25)] || 1;
461
- const q2 = nonZero[Math.floor(nonZero.length * 0.5)] || 2;
462
- const q3 = nonZero[Math.floor(nonZero.length * 0.75)] || 3;
463
-
464
- function level(count) {
465
- if (count === 0) return "";
466
- if (count <= q1) return " l1";
467
- if (count <= q2) return " l2";
468
- if (count <= q3) return " l3";
469
- return " l4";
470
- }
471
-
472
- const start = new Date(firstDate);
473
- start.setUTCDate(start.getUTCDate() - start.getUTCDay());
474
-
475
- const months = [];
476
- let lastMonth = -1;
477
- const cursor1 = new Date(start);
478
- let weekIdx = 0;
479
- while (cursor1 <= lastDate) {
480
- if (cursor1.getUTCDay() === 0) {
481
- const m = cursor1.getUTCMonth();
482
- if (m !== lastMonth) {
483
- months.push({
484
- name: cursor1.toLocaleString("en", { month: "short", timeZone: "UTC" }),
485
- week: weekIdx,
486
- });
487
- lastMonth = m;
488
- }
489
- weekIdx++;
490
- }
491
- cursor1.setUTCDate(cursor1.getUTCDate() + 1);
492
- }
493
- const totalWeeks = weekIdx;
494
- const monthLabels = months
495
- .map((m) => {
496
- const left = totalWeeks > 0 ? Math.round((m.week / totalWeeks) * 100) : 0;
497
- return `<span class="heatmap-month" style="position:absolute;left:${left}%">${m.name}</span>`;
498
- })
499
- .join("");
500
-
501
- let cells = "";
502
- const cursor2 = new Date(start);
503
- while (cursor2 <= lastDate) {
504
- const key = cursor2.toISOString().slice(0, 10);
505
- const count = dateMap.get(key) || 0;
506
- cells += `<div class="heatmap-cell${level(count)}" title="${esc(key)}: ${count} messages"></div>`;
507
- cursor2.setUTCDate(cursor2.getUTCDate() + 1);
508
- }
509
-
510
- content += `<div class="label">Activity</div>
511
- <div style="position:relative;margin-bottom:.5rem">
512
- <div class="heatmap-months" style="position:relative;height:.8rem">${monthLabels}</div>
513
- <div style="overflow-x:auto"><div class="heatmap">${cells}</div></div>
514
- </div>`;
515
- }
516
-
517
- // Peak hours
518
- if (hasHours) {
519
- const maxHour = Math.max(...Object.values(hourCounts), 1);
520
- let bars = "";
521
- let labels = "";
522
- for (let h = 0; h < 24; h++) {
523
- const count = hourCounts[String(h)] || 0;
524
- const pct = Math.round((count / maxHour) * 100);
525
- bars += `<div class="peak-bar" style="height:${Math.max(pct, 2)}%" title="${esc(String(h))}:00 — ${count} messages"></div>`;
526
- labels += `<div class="peak-label">${h % 6 === 0 ? h : ""}</div>`;
527
- }
528
- content += `<div class="label" style="margin-top:.75rem">Peak Hours</div>
529
- <div class="peak-hours">${bars}</div>
530
- <div class="peak-labels">${labels}</div>`;
531
- }
532
-
533
- // Model usage
534
- if (ccusageData) {
535
- const modelCosts = {};
536
- for (const day of ccusageData.daily) {
537
- for (const mb of day.modelBreakdowns || []) {
538
- if (!modelCosts[mb.modelName]) modelCosts[mb.modelName] = { cost: 0, tokens: 0 };
539
- modelCosts[mb.modelName].cost += mb.cost || 0;
540
- modelCosts[mb.modelName].tokens +=
541
- (mb.inputTokens || 0) +
542
- (mb.outputTokens || 0) +
543
- (mb.cacheCreationTokens || 0) +
544
- (mb.cacheReadTokens || 0);
545
- }
546
- }
547
- const modelRows = Object.entries(modelCosts)
548
- .sort((a, b) => b[1].cost - a[1].cost)
549
- .map(
550
- ([name, data]) =>
551
- `<div class="model-row"><span class="model-name">${esc(name)}</span><span class="model-tokens">$${Math.round(data.cost).toLocaleString()} · ${formatTokens(data.tokens)}</span></div>`,
552
- )
553
- .join("\n ");
554
-
555
- const t = ccusageData.totals;
556
- const breakdownHtml = `<div class="token-breakdown">
557
- <div class="tb-row"><span class="tb-label">Cache Read</span><span class="tb-val">${formatTokens(t.cacheReadTokens)}</span></div>
558
- <div class="tb-row"><span class="tb-label">Cache Creation</span><span class="tb-val">${formatTokens(t.cacheCreationTokens)}</span></div>
559
- <div class="tb-row"><span class="tb-label">Output</span><span class="tb-val">${formatTokens(t.outputTokens)}</span></div>
560
- <div class="tb-row"><span class="tb-label">Input</span><span class="tb-val">${formatTokens(t.inputTokens)}</span></div>
561
- </div>`;
562
-
563
- content += `<div class="label" style="margin-top:.75rem">Model Usage (via ccusage)</div>
564
- ${modelRows}
565
- <div class="label" style="margin-top:.75rem">Token Breakdown</div>
566
- ${breakdownHtml}`;
567
- } else if (hasModels) {
568
- const modelRows = Object.entries(modelUsage)
569
- .map(([name, usage]) => {
570
- const total = (usage.inputTokens || 0) + (usage.outputTokens || 0);
571
- return { name, total };
572
- })
573
- .sort((a, b) => b.total - a.total)
574
- .map(
575
- (m) =>
576
- `<div class="model-row"><span class="model-name">${esc(m.name)}</span><span class="model-tokens">${formatTokens(m.total)}</span></div>`,
577
- )
578
- .join("\n ");
579
- content += `<div class="label" style="margin-top:.75rem">Model Usage (partial — install ccusage for full data)</div>
580
- ${modelRows}`;
581
- }
582
-
583
- return `<div class="card full">
584
- <h2>Activity</h2>
585
- ${content}
586
- </div>`;
587
- })()}
588
- <details class="card full">
589
- <summary style="cursor:pointer;list-style:none"><h2 style="margin:0">Quick Reference</h2></summary>
590
- <div style="margin-top:.75rem">
591
- <div class="ref-grid">
592
- <div class="ref-col">
593
- <div class="label">Essential Commands</div>
594
- ${QUICK_REFERENCE.essentialCommands.map((c) => `<div class="ref-row"><code class="ref-cmd">${esc(c.cmd)}</code><span class="ref-desc">${esc(c.desc)}</span></div>`).join("\n ")}
595
- </div>
596
- <div class="ref-col">
597
- <div class="label">Built-in Tools</div>
598
- ${QUICK_REFERENCE.tools.map((t) => `<div class="ref-row"><code class="ref-cmd">${esc(t.name)}</code><span class="ref-desc">${esc(t.desc)}</span></div>`).join("\n ")}
599
- <div class="label" style="margin-top:.75rem">Keyboard Shortcuts</div>
600
- ${QUICK_REFERENCE.shortcuts.map((s) => `<div class="ref-row"><kbd class="ref-key">${esc(s.keys)}</kbd><span class="ref-desc">${esc(s.desc)}</span></div>`).join("\n ")}
601
- </div>
662
+ <nav class="tab-nav">
663
+ <button class="tab-btn active" data-tab="overview">Overview</button>
664
+ <button class="tab-btn" data-tab="skills-mcp">Skills & MCP</button>
665
+ <button class="tab-btn" data-tab="analytics">Analytics</button>
666
+ <button class="tab-btn" data-tab="repos">Repos</button>
667
+ <button class="tab-btn" data-tab="reference">Reference</button>
668
+ </nav>
669
+
670
+ <div class="tab-content active" id="tab-overview">
671
+ <div class="top-grid">
672
+ <div class="card" style="margin-bottom:0">
673
+ <h2>Global Commands <span class="n">${globalCmds.length}</span></h2>
674
+ ${globalCmds.map((c) => renderCmd(c)).join("\n ")}
675
+ </div>
676
+ <div class="card" style="margin-bottom:0">
677
+ <h2>Global Rules <span class="n">${globalRules.length}</span></h2>
678
+ ${globalRules.map((r) => renderRule(r)).join("\n ")}
602
679
  </div>
603
680
  </div>
604
- </details>
605
- ${
606
- chains.length
607
- ? `<div class="card full">
608
- <h2>Dependency Chains</h2>
609
- ${chains.map((c) => `<div class="chain">${c.nodes.map((n, i) => `<span class="chain-node">${esc(n.trim())}</span>${i < c.nodes.length - 1 ? `<span class="chain-arrow">${c.arrow}</span>` : ""}`).join("")}</div>`).join("\n ")}
610
- </div>`
611
- : ""
612
- }
681
+ ${chainsHtml}
682
+ ${consolidationHtml}
613
683
  </div>
614
684
 
615
- ${
616
- consolidationGroups.length
617
- ? `<div class="card full" style="margin-bottom:1.25rem">
618
- <h2>Consolidation Opportunities <span class="n">${consolidationGroups.length}</span></h2>
619
- ${consolidationGroups.map((g) => `<div class="consolidation-hint"><span class="consolidation-stack">${esc(g.stack)}</span> <span class="consolidation-text">${esc(g.suggestion)}</span></div>`).join("\n ")}
620
- </div>`
621
- : ""
622
- }
623
-
624
- <div class="search-bar">
625
- <input type="text" id="search" placeholder="search repos..." autocomplete="off">
626
- <span class="search-hint"><kbd>/</kbd></span>
627
- </div>
628
- <div class="group-controls">
629
- <label class="group-label">Group by:</label>
630
- <select id="group-by" class="group-select">
631
- <option value="none">None</option>
632
- <option value="stack">Tech Stack</option>
633
- <option value="parent">Parent Directory</option>
634
- </select>
685
+ <div class="tab-content" id="tab-skills-mcp">
686
+ ${skillsHtml}
687
+ ${mcpHtml}
635
688
  </div>
636
689
 
637
- <div class="repo-grid" id="repo-grid">
638
- ${configured.map((r) => renderRepoCard(r)).join("\n")}
690
+ <div class="tab-content" id="tab-analytics">
691
+ <div class="top-grid">
692
+ ${toolsHtml || ""}
693
+ ${langsHtml || ""}
694
+ </div>
695
+ ${errorsHtml}
696
+ ${activityHtml}
639
697
  </div>
640
698
 
641
- ${
642
- unconfigured.length
643
- ? `<details class="card" style="margin-bottom:1.25rem">
644
- <summary style="cursor:pointer;list-style:none"><h2 style="margin:0">Unconfigured Repos <span class="n">${unconfiguredCount}</span></h2></summary>
645
- <div style="margin-top:.75rem">
646
- <div class="unconfigured-grid">
647
- ${unconfigured
648
- .map((r) => {
649
- const stackTag =
650
- r.techStack && r.techStack.length
651
- ? `<span class="stack-tag">${esc(r.techStack.join(", "))}</span>`
652
- : "";
653
- const suggestionsHtml =
654
- r.suggestions && r.suggestions.length
655
- ? `<div class="suggestion-hints">${r.suggestions.map((s) => `<span class="suggestion-hint">${esc(s)}</span>`).join("")}</div>`
656
- : "";
657
- return `<div class="unconfigured-item">${esc(r.name)}${stackTag}<span class="upath">${esc(r.shortPath)}</span>${suggestionsHtml}</div>`;
658
- })
659
- .join("\n ")}
660
- </div>
699
+ <div class="tab-content" id="tab-repos">
700
+ <div class="search-bar">
701
+ <input type="text" id="search" placeholder="search repos..." autocomplete="off">
702
+ <span class="search-hint"><kbd>/</kbd></span>
661
703
  </div>
662
- </details>`
663
- : ""
664
- }
704
+ <div class="group-controls">
705
+ <label class="group-label">Group by:</label>
706
+ <select id="group-by" class="group-select">
707
+ <option value="none">None</option>
708
+ <option value="stack">Tech Stack</option>
709
+ <option value="parent">Parent Directory</option>
710
+ </select>
711
+ </div>
712
+ <div class="repo-grid" id="repo-grid">
713
+ ${configured.map((r) => renderRepoCard(r)).join("\n")}
714
+ </div>
715
+ ${unconfiguredHtml}
716
+ </div>
717
+
718
+ <div class="tab-content" id="tab-reference">
719
+ ${referenceHtml}
720
+ </div>
665
721
 
666
722
  <div class="ts">found ${totalRepos} repos · ${configuredCount} configured · ${unconfiguredCount} unconfigured · scanned ${scanScope} · ${timestamp}</div>
667
723
 
668
724
  <script>
669
- const input = document.getElementById('search');
670
- const hint = document.querySelector('.search-hint');
725
+ document.querySelectorAll('.tab-btn').forEach(function(btn) {
726
+ btn.addEventListener('click', function() {
727
+ document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
728
+ document.querySelectorAll('.tab-content').forEach(function(c) { c.classList.remove('active'); });
729
+ btn.classList.add('active');
730
+ document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
731
+ });
732
+ });
733
+
734
+ var input = document.getElementById('search');
735
+ var hint = document.querySelector('.search-hint');
671
736
 
672
737
  input.addEventListener('input', function(e) {
673
- const q = e.target.value.toLowerCase();
738
+ var q = e.target.value.toLowerCase();
674
739
  hint.style.display = q ? 'none' : '';
675
740
  document.querySelectorAll('.repo-card').forEach(function(card) {
676
- const name = card.dataset.name.toLowerCase();
677
- const path = (card.dataset.path || '').toLowerCase();
678
- const text = card.textContent.toLowerCase();
741
+ var name = card.dataset.name.toLowerCase();
742
+ var path = (card.dataset.path || '').toLowerCase();
743
+ var text = card.textContent.toLowerCase();
679
744
  card.style.display = (q === '' || name.includes(q) || path.includes(q) || text.includes(q)) ? '' : 'none';
680
745
  });
681
746
  });
@@ -683,6 +748,11 @@ input.addEventListener('input', function(e) {
683
748
  document.addEventListener('keydown', function(e) {
684
749
  if (e.key === '/' && document.activeElement !== input) {
685
750
  e.preventDefault();
751
+ // Switch to repos tab first
752
+ document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
753
+ document.querySelectorAll('.tab-content').forEach(function(c) { c.classList.remove('active'); });
754
+ document.querySelector('[data-tab="repos"]').classList.add('active');
755
+ document.getElementById('tab-repos').classList.add('active');
686
756
  input.focus();
687
757
  }
688
758
  if (e.key === 'Escape' && document.activeElement === input) {
package/src/mcp.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from "fs";
2
2
  import { join } from "path";
3
+ import { MAX_SESSION_SCAN } from "./constants.mjs";
3
4
 
4
5
  export function parseUserMcpConfig(content) {
5
6
  try {
@@ -46,30 +47,85 @@ export function findPromotionCandidates(servers) {
46
47
  .sort((a, b) => b.projects.length - a.projects.length || a.name.localeCompare(b.name));
47
48
  }
48
49
 
50
+ /**
51
+ * Scan file-history snapshots for MCP server usage, enriched with project paths
52
+ * and timestamps from session-meta. Returns a map of server name → metadata.
53
+ *
54
+ * Each entry: { name, projects: Set<string>, lastSeen: Date|null }
55
+ */
49
56
  export function scanHistoricalMcpServers(claudeDir) {
50
- const historical = new Set();
51
57
  const fileHistoryDir = join(claudeDir, "file-history");
52
- if (!existsSync(fileHistoryDir)) return [];
53
- const MAX_SESSION_DIRS = 200;
54
- const MAX_FILES_TOTAL = 1000;
55
- let filesRead = 0;
58
+ if (!existsSync(fileHistoryDir)) return new Map();
59
+
60
+ // Build session → { projectPath, startTime } lookup from session-meta
61
+ const sessionMeta = new Map();
62
+ const metaDir = join(claudeDir, "usage-data", "session-meta");
63
+ if (existsSync(metaDir)) {
64
+ try {
65
+ for (const file of readdirSync(metaDir)) {
66
+ if (!file.endsWith(".json")) continue;
67
+ try {
68
+ const meta = JSON.parse(readFileSync(join(metaDir, file), "utf8"));
69
+ const sessionId = file.replace(/\.json$/, "");
70
+ sessionMeta.set(sessionId, {
71
+ projectPath: meta.project_path || null,
72
+ startTime: meta.start_time ? new Date(meta.start_time) : null,
73
+ });
74
+ } catch {
75
+ /* skip malformed meta */
76
+ }
77
+ }
78
+ } catch {
79
+ /* skip unreadable meta dir */
80
+ }
81
+ }
82
+
83
+ const servers = new Map(); // name → { name, projects: Set, lastSeen: Date|null }
84
+
56
85
  try {
57
- const sessionDirs = readdirSync(fileHistoryDir).sort().slice(-MAX_SESSION_DIRS);
86
+ const sessionDirs = readdirSync(fileHistoryDir).sort().slice(-MAX_SESSION_SCAN);
58
87
  for (const sessionDir of sessionDirs) {
59
- if (filesRead >= MAX_FILES_TOTAL) break;
60
88
  const sessionPath = join(fileHistoryDir, sessionDir);
61
- if (!statSync(sessionPath).isDirectory()) continue;
62
89
  try {
63
- for (const snapFile of readdirSync(sessionPath)) {
64
- if (filesRead >= MAX_FILES_TOTAL) break;
65
- filesRead++;
66
- const snapPath = join(sessionPath, snapFile);
90
+ if (!statSync(sessionPath).isDirectory()) continue;
91
+ } catch {
92
+ continue;
93
+ }
94
+
95
+ const meta = sessionMeta.get(sessionDir);
96
+ const projectPath = meta?.projectPath || null;
97
+ const startTime = meta?.startTime || null;
98
+
99
+ try {
100
+ // Only read the latest version of each file hash (highest @vN)
101
+ const files = readdirSync(sessionPath);
102
+ const latestByHash = new Map();
103
+ for (const f of files) {
104
+ const atIdx = f.indexOf("@v");
105
+ if (atIdx < 0) continue;
106
+ const hash = f.slice(0, atIdx);
107
+ const ver = parseInt(f.slice(atIdx + 2), 10) || 0;
108
+ const prev = latestByHash.get(hash);
109
+ if (!prev || ver > prev.ver) {
110
+ latestByHash.set(hash, { file: f, ver });
111
+ }
112
+ }
113
+
114
+ for (const { file } of latestByHash.values()) {
115
+ const snapPath = join(sessionPath, file);
67
116
  try {
68
117
  const content = readFileSync(snapPath, "utf8");
69
118
  if (!content.includes("mcpServers")) continue;
70
119
  const data = JSON.parse(content);
71
120
  for (const name of Object.keys(data.mcpServers || {})) {
72
- historical.add(name);
121
+ if (!servers.has(name)) {
122
+ servers.set(name, { name, projects: new Set(), lastSeen: null });
123
+ }
124
+ const entry = servers.get(name);
125
+ if (projectPath) entry.projects.add(projectPath);
126
+ if (startTime && (!entry.lastSeen || startTime > entry.lastSeen)) {
127
+ entry.lastSeen = startTime;
128
+ }
73
129
  }
74
130
  } catch {
75
131
  /* skip malformed */
@@ -82,5 +138,40 @@ export function scanHistoricalMcpServers(claudeDir) {
82
138
  } catch {
83
139
  /* skip unreadable file-history dir */
84
140
  }
85
- return [...historical];
141
+ return servers;
142
+ }
143
+
144
+ /**
145
+ * Classify historical MCP servers as "recent" (seen in last recencyDays) or "former".
146
+ * Recent servers are merged into the current server list if not already present.
147
+ */
148
+ export function classifyHistoricalServers(
149
+ historicalMap,
150
+ currentNames,
151
+ recencyDays = 30,
152
+ now = null,
153
+ ) {
154
+ const cutoff = new Date(now || Date.now());
155
+ cutoff.setDate(cutoff.getDate() - recencyDays);
156
+
157
+ const recent = [];
158
+ const former = [];
159
+
160
+ for (const [name, entry] of historicalMap) {
161
+ if (currentNames.has(name)) continue; // already in current config
162
+ const info = {
163
+ name,
164
+ projects: [...entry.projects].sort(),
165
+ lastSeen: entry.lastSeen,
166
+ };
167
+ if (entry.lastSeen && entry.lastSeen >= cutoff) {
168
+ recent.push(info);
169
+ } else {
170
+ former.push(info);
171
+ }
172
+ }
173
+
174
+ recent.sort((a, b) => a.name.localeCompare(b.name));
175
+ former.sort((a, b) => a.name.localeCompare(b.name));
176
+ return { recent, former };
86
177
  }
package/src/render.mjs CHANGED
@@ -130,6 +130,15 @@ export function renderRepoCard(repo) {
130
130
  .join("")}</div>`;
131
131
  }
132
132
 
133
+ if (repo.similarRepos && repo.similarRepos.length) {
134
+ body += `<div class="label">Similar Configs</div>`;
135
+ body += `<div class="similar-repos">${repo.similarRepos
136
+ .map(
137
+ (r) => `<span class="similar-repo">${esc(r.name)} <small>${r.similarity}%</small></span>`,
138
+ )
139
+ .join("")}</div>`;
140
+ }
141
+
133
142
  if (repo.sections.length) {
134
143
  body += `<div class="label">Agent Config</div>`;
135
144
  body += renderSections(repo.sections);