@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 +1 -1
- package/generate-dashboard.mjs +63 -9
- package/package.json +1 -1
- package/src/anonymize.mjs +6 -2
- package/src/cli.mjs +8 -4
- package/src/constants.mjs +4 -1
- package/src/demo.mjs +12 -1
- package/src/html-template.mjs +425 -355
- package/src/mcp.mjs +105 -14
- package/src/render.mjs +9 -0
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
|
-

|
|
37
37
|
|
|
38
38
|
> Screenshots generated with `claude-code-dashboard --demo`
|
|
39
39
|
|
package/generate-dashboard.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 >=
|
|
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 =
|
|
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
|
-
|
|
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
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] =
|
|
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
|
|
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.
|
|
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: [
|
|
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",
|
package/src/html-template.mjs
CHANGED
|
@@ -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 → 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:
|
|
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
|
|
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,
|
|
195
|
-
.heatmap-cell {
|
|
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 ·
|
|
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
|
-
<
|
|
315
|
-
<
|
|
316
|
-
<
|
|
317
|
-
|
|
318
|
-
</
|
|
319
|
-
<
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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 → 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
|
-
|
|
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
|
-
|
|
617
|
-
|
|
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="
|
|
638
|
-
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
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
|
-
|
|
670
|
-
|
|
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
|
-
|
|
738
|
+
var q = e.target.value.toLowerCase();
|
|
674
739
|
hint.style.display = q ? 'none' : '';
|
|
675
740
|
document.querySelectorAll('.repo-card').forEach(function(card) {
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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(-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|
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);
|