@viren/claude-code-dashboard 0.0.1
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/LICENSE +21 -0
- package/README.md +195 -0
- package/generate-dashboard.mjs +637 -0
- package/package.json +42 -0
- package/src/analysis.mjs +262 -0
- package/src/cli.mjs +135 -0
- package/src/constants.mjs +150 -0
- package/src/discovery.mjs +46 -0
- package/src/freshness.mjs +35 -0
- package/src/helpers.mjs +42 -0
- package/src/html-template.mjs +744 -0
- package/src/markdown.mjs +142 -0
- package/src/mcp.mjs +86 -0
- package/src/render.mjs +264 -0
- package/src/skills.mjs +135 -0
- package/src/templates.mjs +221 -0
- package/src/usage.mjs +60 -0
- package/src/watch.mjs +54 -0
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Claude Code Dashboard Generator
|
|
4
|
+
*
|
|
5
|
+
* Scans your home directory for git repos, collects Claude Code configuration
|
|
6
|
+
* (commands, rules, AGENTS.md/CLAUDE.md), and generates a self-contained
|
|
7
|
+
* HTML dashboard.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* npx claude-code-dashboard
|
|
11
|
+
* node generate-dashboard.mjs [--output path] [--open] [--help] [--version]
|
|
12
|
+
*
|
|
13
|
+
* Config: ~/.claude/dashboard.conf (optional)
|
|
14
|
+
* - One directory per line to restrict scanning scope
|
|
15
|
+
* - chain: A -> B -> C to define dependency chains
|
|
16
|
+
* - Lines starting with # are comments
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { execFileSync, execFile } from "child_process";
|
|
20
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from "fs";
|
|
21
|
+
import { join, basename, dirname } from "path";
|
|
22
|
+
|
|
23
|
+
import { VERSION, HOME, CLAUDE_DIR, DEFAULT_OUTPUT, CONF, MAX_DEPTH } from "./src/constants.mjs";
|
|
24
|
+
import { parseArgs, generateCompletions } from "./src/cli.mjs";
|
|
25
|
+
import { shortPath, anonymizePath } from "./src/helpers.mjs";
|
|
26
|
+
import { findGitRepos, getScanRoots } from "./src/discovery.mjs";
|
|
27
|
+
import { extractProjectDesc, extractSections, scanMdDir } from "./src/markdown.mjs";
|
|
28
|
+
import { scanSkillsDir, groupSkillsByCategory } from "./src/skills.mjs";
|
|
29
|
+
import {
|
|
30
|
+
computeHealthScore,
|
|
31
|
+
detectTechStack,
|
|
32
|
+
computeDrift,
|
|
33
|
+
findExemplar,
|
|
34
|
+
generateSuggestions,
|
|
35
|
+
detectConfigPattern,
|
|
36
|
+
computeConfigSimilarity,
|
|
37
|
+
matchSkillsToRepo,
|
|
38
|
+
lintConfig,
|
|
39
|
+
computeDashboardDiff,
|
|
40
|
+
} from "./src/analysis.mjs";
|
|
41
|
+
import { getFreshness, relativeTime, freshnessClass } from "./src/freshness.mjs";
|
|
42
|
+
import {
|
|
43
|
+
parseUserMcpConfig,
|
|
44
|
+
parseProjectMcpConfig,
|
|
45
|
+
findPromotionCandidates,
|
|
46
|
+
scanHistoricalMcpServers,
|
|
47
|
+
} from "./src/mcp.mjs";
|
|
48
|
+
import { aggregateSessionMeta } from "./src/usage.mjs";
|
|
49
|
+
import { handleInit } from "./src/templates.mjs";
|
|
50
|
+
import { generateCatalogHtml } from "./src/render.mjs";
|
|
51
|
+
import { generateDashboardHtml } from "./src/html-template.mjs";
|
|
52
|
+
import { startWatch } from "./src/watch.mjs";
|
|
53
|
+
|
|
54
|
+
// ── CLI ──────────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
const cliArgs = parseArgs(process.argv);
|
|
57
|
+
|
|
58
|
+
if (cliArgs.completions) generateCompletions();
|
|
59
|
+
if (cliArgs.command === "init") handleInit(cliArgs);
|
|
60
|
+
|
|
61
|
+
// ── Collect Everything ───────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
const scanRoots = getScanRoots();
|
|
64
|
+
const allRepoPaths = findGitRepos(scanRoots, MAX_DEPTH);
|
|
65
|
+
|
|
66
|
+
const globalCmds = scanMdDir(join(CLAUDE_DIR, "commands"));
|
|
67
|
+
const globalRules = scanMdDir(join(CLAUDE_DIR, "rules"));
|
|
68
|
+
const globalSkills = scanSkillsDir(join(CLAUDE_DIR, "skills"));
|
|
69
|
+
|
|
70
|
+
const configured = [];
|
|
71
|
+
const unconfigured = [];
|
|
72
|
+
const seenNames = new Map();
|
|
73
|
+
|
|
74
|
+
for (const repoDir of allRepoPaths) {
|
|
75
|
+
const name = basename(repoDir);
|
|
76
|
+
|
|
77
|
+
// Collision-safe display key
|
|
78
|
+
const count = (seenNames.get(name) || 0) + 1;
|
|
79
|
+
seenNames.set(name, count);
|
|
80
|
+
const key = count > 1 ? `${name}__${count}` : name;
|
|
81
|
+
|
|
82
|
+
const repo = {
|
|
83
|
+
key,
|
|
84
|
+
name,
|
|
85
|
+
path: repoDir,
|
|
86
|
+
shortPath: shortPath(repoDir),
|
|
87
|
+
commands: scanMdDir(join(repoDir, ".claude", "commands")),
|
|
88
|
+
rules: scanMdDir(join(repoDir, ".claude", "rules")),
|
|
89
|
+
desc: [],
|
|
90
|
+
sections: [],
|
|
91
|
+
freshness: 0,
|
|
92
|
+
freshnessText: "",
|
|
93
|
+
freshnessClass: "stale",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// AGENTS.md / CLAUDE.md
|
|
97
|
+
let agentsFile = null;
|
|
98
|
+
if (existsSync(join(repoDir, "AGENTS.md"))) agentsFile = join(repoDir, "AGENTS.md");
|
|
99
|
+
else if (existsSync(join(repoDir, "CLAUDE.md"))) agentsFile = join(repoDir, "CLAUDE.md");
|
|
100
|
+
|
|
101
|
+
if (agentsFile) {
|
|
102
|
+
repo.desc = extractProjectDesc(agentsFile);
|
|
103
|
+
repo.sections = extractSections(agentsFile);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const hasConfig = repo.commands.length > 0 || repo.rules.length > 0 || agentsFile;
|
|
107
|
+
|
|
108
|
+
// Tech stack (for both configured and unconfigured)
|
|
109
|
+
const stackInfo = detectTechStack(repoDir);
|
|
110
|
+
repo.techStack = stackInfo.stacks;
|
|
111
|
+
|
|
112
|
+
if (hasConfig) {
|
|
113
|
+
repo.freshness = getFreshness(repoDir);
|
|
114
|
+
repo.freshnessText = relativeTime(repo.freshness);
|
|
115
|
+
repo.freshnessClass = freshnessClass(repo.freshness);
|
|
116
|
+
|
|
117
|
+
// Health score
|
|
118
|
+
const health = computeHealthScore({
|
|
119
|
+
hasAgentsFile: !!agentsFile,
|
|
120
|
+
desc: repo.desc,
|
|
121
|
+
commandCount: repo.commands.length,
|
|
122
|
+
ruleCount: repo.rules.length,
|
|
123
|
+
sectionCount: repo.sections.length,
|
|
124
|
+
freshnessClass: repo.freshnessClass,
|
|
125
|
+
});
|
|
126
|
+
repo.healthScore = health.score;
|
|
127
|
+
repo.healthReasons = health.reasons;
|
|
128
|
+
repo.hasAgentsFile = !!agentsFile;
|
|
129
|
+
repo.configPattern = detectConfigPattern(repo);
|
|
130
|
+
|
|
131
|
+
// Drift detection
|
|
132
|
+
const drift = computeDrift(repoDir, repo.freshness);
|
|
133
|
+
repo.drift = drift;
|
|
134
|
+
|
|
135
|
+
configured.push(repo);
|
|
136
|
+
} else {
|
|
137
|
+
unconfigured.push(repo);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Sort configured by richness (most config first)
|
|
142
|
+
configured.sort((a, b) => {
|
|
143
|
+
const score = (r) =>
|
|
144
|
+
r.commands.length * 3 + r.rules.length * 2 + r.sections.length + (r.desc.length > 0 ? 1 : 0);
|
|
145
|
+
return score(b) - score(a);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
unconfigured.sort((a, b) => a.name.localeCompare(b.name));
|
|
149
|
+
|
|
150
|
+
// Compute suggestions for unconfigured repos
|
|
151
|
+
for (const repo of unconfigured) {
|
|
152
|
+
const exemplar = findExemplar(repo.techStack, configured);
|
|
153
|
+
if (exemplar) {
|
|
154
|
+
repo.suggestions = generateSuggestions(exemplar);
|
|
155
|
+
repo.exemplarName = exemplar.name;
|
|
156
|
+
} else {
|
|
157
|
+
repo.suggestions = [];
|
|
158
|
+
repo.exemplarName = "";
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Compute similar repos for configured repos
|
|
163
|
+
for (const repo of configured) {
|
|
164
|
+
const similar = configured
|
|
165
|
+
.filter((r) => r !== repo)
|
|
166
|
+
.map((r) => ({ name: r.name, similarity: computeConfigSimilarity(repo, r) }))
|
|
167
|
+
.filter((r) => r.similarity >= 40)
|
|
168
|
+
.sort((a, b) => b.similarity - a.similarity)
|
|
169
|
+
.slice(0, 2);
|
|
170
|
+
repo.similarRepos = similar;
|
|
171
|
+
repo.matchedSkills = matchSkillsToRepo(repo, globalSkills);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Detect consolidation opportunities
|
|
175
|
+
const consolidationGroups = [];
|
|
176
|
+
const byStack = {};
|
|
177
|
+
for (const repo of configured) {
|
|
178
|
+
for (const s of repo.techStack || []) {
|
|
179
|
+
if (!byStack[s]) byStack[s] = [];
|
|
180
|
+
byStack[s].push(repo);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
for (const [stack, repos] of Object.entries(byStack)) {
|
|
184
|
+
if (repos.length >= 3) {
|
|
185
|
+
let pairCount = 0;
|
|
186
|
+
let simSum = 0;
|
|
187
|
+
for (let i = 0; i < repos.length; i++) {
|
|
188
|
+
for (let j = i + 1; j < repos.length; j++) {
|
|
189
|
+
simSum += computeConfigSimilarity(repos[i], repos[j]);
|
|
190
|
+
pairCount++;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const avgSimilarity = pairCount > 0 ? Math.round(simSum / pairCount) : 0;
|
|
194
|
+
if (avgSimilarity >= 30) {
|
|
195
|
+
consolidationGroups.push({
|
|
196
|
+
stack,
|
|
197
|
+
repos: repos.map((r) => r.name),
|
|
198
|
+
avgSimilarity,
|
|
199
|
+
suggestion: `${repos.length} ${stack} repos with ${avgSimilarity}% avg similarity — consider shared global rules`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Dependency chains from config
|
|
206
|
+
function parseChains() {
|
|
207
|
+
if (!existsSync(CONF)) return [];
|
|
208
|
+
const chains = [];
|
|
209
|
+
for (const line of readFileSync(CONF, "utf8").split("\n")) {
|
|
210
|
+
const m = line.match(/^chain:\s*(.+)/i);
|
|
211
|
+
if (!m) continue;
|
|
212
|
+
const raw = m[1];
|
|
213
|
+
if (raw.includes("<-")) {
|
|
214
|
+
chains.push({ nodes: raw.split(/\s*<-\s*/), arrow: "←" });
|
|
215
|
+
} else {
|
|
216
|
+
chains.push({ nodes: raw.split(/\s*->\s*/), arrow: "→" });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return chains;
|
|
220
|
+
}
|
|
221
|
+
const chains = parseChains();
|
|
222
|
+
|
|
223
|
+
// MCP Server Discovery
|
|
224
|
+
const allMcpServers = [];
|
|
225
|
+
|
|
226
|
+
const userMcpPath = join(CLAUDE_DIR, "mcp_config.json");
|
|
227
|
+
if (existsSync(userMcpPath)) {
|
|
228
|
+
try {
|
|
229
|
+
const content = readFileSync(userMcpPath, "utf8");
|
|
230
|
+
allMcpServers.push(...parseUserMcpConfig(content));
|
|
231
|
+
} catch {
|
|
232
|
+
// skip if unreadable
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
for (const repoDir of allRepoPaths) {
|
|
237
|
+
const mcpPath = join(repoDir, ".mcp.json");
|
|
238
|
+
if (existsSync(mcpPath)) {
|
|
239
|
+
try {
|
|
240
|
+
const content = readFileSync(mcpPath, "utf8");
|
|
241
|
+
const servers = parseProjectMcpConfig(content, shortPath(repoDir));
|
|
242
|
+
allMcpServers.push(...servers);
|
|
243
|
+
const repo =
|
|
244
|
+
configured.find((r) => r.path === repoDir) || unconfigured.find((r) => r.path === repoDir);
|
|
245
|
+
if (repo) repo.mcpServers = servers;
|
|
246
|
+
} catch {
|
|
247
|
+
// skip if unreadable
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Disabled MCP servers
|
|
253
|
+
const disabledMcpByRepo = {};
|
|
254
|
+
const claudeJsonPath = join(HOME, ".claude.json");
|
|
255
|
+
if (existsSync(claudeJsonPath)) {
|
|
256
|
+
try {
|
|
257
|
+
const claudeJsonContent = readFileSync(claudeJsonPath, "utf8");
|
|
258
|
+
const claudeJson = JSON.parse(claudeJsonContent);
|
|
259
|
+
for (const [path, entry] of Object.entries(claudeJson)) {
|
|
260
|
+
if (
|
|
261
|
+
typeof entry === "object" &&
|
|
262
|
+
entry !== null &&
|
|
263
|
+
Array.isArray(entry.disabledMcpServers) &&
|
|
264
|
+
entry.disabledMcpServers.length > 0
|
|
265
|
+
) {
|
|
266
|
+
disabledMcpByRepo[path] = entry.disabledMcpServers;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
// skip if parse fails
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const mcpPromotions = findPromotionCandidates(allMcpServers);
|
|
275
|
+
|
|
276
|
+
const disabledByServer = {};
|
|
277
|
+
for (const [, names] of Object.entries(disabledMcpByRepo)) {
|
|
278
|
+
for (const name of names) {
|
|
279
|
+
disabledByServer[name] = (disabledByServer[name] || 0) + 1;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const mcpByName = {};
|
|
284
|
+
for (const s of allMcpServers) {
|
|
285
|
+
if (!mcpByName[s.name])
|
|
286
|
+
mcpByName[s.name] = {
|
|
287
|
+
name: s.name,
|
|
288
|
+
type: s.type,
|
|
289
|
+
projects: [],
|
|
290
|
+
userLevel: false,
|
|
291
|
+
disabledIn: 0,
|
|
292
|
+
};
|
|
293
|
+
if (s.scope === "user") mcpByName[s.name].userLevel = true;
|
|
294
|
+
if (s.scope === "project") mcpByName[s.name].projects.push(s.source);
|
|
295
|
+
}
|
|
296
|
+
for (const entry of Object.values(mcpByName)) {
|
|
297
|
+
entry.disabledIn = disabledByServer[entry.name] || 0;
|
|
298
|
+
}
|
|
299
|
+
const mcpSummary = Object.values(mcpByName).sort((a, b) => {
|
|
300
|
+
if (a.userLevel !== b.userLevel) return a.userLevel ? -1 : 1;
|
|
301
|
+
return a.name.localeCompare(b.name);
|
|
302
|
+
});
|
|
303
|
+
const mcpCount = mcpSummary.length;
|
|
304
|
+
|
|
305
|
+
const historicalMcpNames = scanHistoricalMcpServers(CLAUDE_DIR);
|
|
306
|
+
const currentMcpNames = new Set(allMcpServers.map((s) => s.name));
|
|
307
|
+
const formerMcpServers = historicalMcpNames.filter((name) => !currentMcpNames.has(name)).sort();
|
|
308
|
+
|
|
309
|
+
// ── Usage Analytics ──────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
const SESSION_META_LIMIT = 500;
|
|
312
|
+
const sessionMetaDir = join(CLAUDE_DIR, "usage-data", "session-meta");
|
|
313
|
+
const sessionMetaFiles = [];
|
|
314
|
+
if (existsSync(sessionMetaDir)) {
|
|
315
|
+
try {
|
|
316
|
+
const files = readdirSync(sessionMetaDir)
|
|
317
|
+
.filter((f) => f.endsWith(".json"))
|
|
318
|
+
.sort()
|
|
319
|
+
.slice(-SESSION_META_LIMIT);
|
|
320
|
+
for (const f of files) {
|
|
321
|
+
try {
|
|
322
|
+
const content = readFileSync(join(sessionMetaDir, f), "utf8");
|
|
323
|
+
sessionMetaFiles.push(JSON.parse(content));
|
|
324
|
+
} catch {
|
|
325
|
+
// skip unparseable files
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
} catch {
|
|
329
|
+
// skip if directory unreadable
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const usageAnalytics = aggregateSessionMeta(sessionMetaFiles);
|
|
333
|
+
|
|
334
|
+
// ccusage integration
|
|
335
|
+
let ccusageData = null;
|
|
336
|
+
const ccusageCachePath = join(CLAUDE_DIR, "ccusage-cache.json");
|
|
337
|
+
const CCUSAGE_TTL_MS = 60 * 60 * 1000;
|
|
338
|
+
|
|
339
|
+
if (!cliArgs.quiet) {
|
|
340
|
+
try {
|
|
341
|
+
const cached = JSON.parse(readFileSync(ccusageCachePath, "utf8"));
|
|
342
|
+
if (cached._ts && Date.now() - cached._ts < CCUSAGE_TTL_MS && cached.totals && cached.daily) {
|
|
343
|
+
ccusageData = cached;
|
|
344
|
+
}
|
|
345
|
+
} catch {
|
|
346
|
+
/* no cache or stale */
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (!ccusageData) {
|
|
350
|
+
try {
|
|
351
|
+
const raw = execFileSync("npx", ["ccusage", "--json"], {
|
|
352
|
+
encoding: "utf8",
|
|
353
|
+
timeout: 30_000,
|
|
354
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
355
|
+
});
|
|
356
|
+
const parsed = JSON.parse(raw);
|
|
357
|
+
if (parsed.totals && parsed.daily) {
|
|
358
|
+
ccusageData = parsed;
|
|
359
|
+
try {
|
|
360
|
+
writeFileSync(ccusageCachePath, JSON.stringify({ ...parsed, _ts: Date.now() }));
|
|
361
|
+
} catch {
|
|
362
|
+
/* non-critical */
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
} catch {
|
|
366
|
+
// ccusage not installed or timed out
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Stats cache
|
|
372
|
+
const statsCachePath = join(CLAUDE_DIR, "stats-cache.json");
|
|
373
|
+
let statsCache = {};
|
|
374
|
+
if (existsSync(statsCachePath)) {
|
|
375
|
+
try {
|
|
376
|
+
statsCache = JSON.parse(readFileSync(statsCachePath, "utf8"));
|
|
377
|
+
} catch {
|
|
378
|
+
// skip if parse fails
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Supplement dailyActivity with session-meta data
|
|
383
|
+
if (sessionMetaFiles.length > 0) {
|
|
384
|
+
const existingDates = new Set((statsCache.dailyActivity || []).map((d) => d.date));
|
|
385
|
+
const sessionDayCounts = {};
|
|
386
|
+
for (const s of sessionMetaFiles) {
|
|
387
|
+
const date = (s.start_time || "").slice(0, 10);
|
|
388
|
+
if (!date || existingDates.has(date)) continue;
|
|
389
|
+
sessionDayCounts[date] =
|
|
390
|
+
(sessionDayCounts[date] || 0) +
|
|
391
|
+
(s.user_message_count || 0) +
|
|
392
|
+
(s.assistant_message_count || 0);
|
|
393
|
+
}
|
|
394
|
+
const supplemental = Object.entries(sessionDayCounts).map(([date, messageCount]) => ({
|
|
395
|
+
date,
|
|
396
|
+
messageCount,
|
|
397
|
+
}));
|
|
398
|
+
if (supplemental.length > 0) {
|
|
399
|
+
statsCache.dailyActivity = [...(statsCache.dailyActivity || []), ...supplemental].sort((a, b) =>
|
|
400
|
+
a.date.localeCompare(b.date),
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── Computed Stats ───────────────────────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
const totalRepos = allRepoPaths.length;
|
|
408
|
+
const configuredCount = configured.length;
|
|
409
|
+
const unconfiguredCount = unconfigured.length;
|
|
410
|
+
const coveragePct = totalRepos > 0 ? Math.round((configuredCount / totalRepos) * 100) : 0;
|
|
411
|
+
const totalRepoCmds = configured.reduce((sum, r) => sum + r.commands.length, 0);
|
|
412
|
+
const avgHealth =
|
|
413
|
+
configured.length > 0
|
|
414
|
+
? Math.round(configured.reduce((sum, r) => sum + (r.healthScore || 0), 0) / configured.length)
|
|
415
|
+
: 0;
|
|
416
|
+
const driftCount = configured.filter(
|
|
417
|
+
(r) => r.drift && (r.drift.level === "medium" || r.drift.level === "high"),
|
|
418
|
+
).length;
|
|
419
|
+
|
|
420
|
+
const now = new Date();
|
|
421
|
+
const timestamp =
|
|
422
|
+
now
|
|
423
|
+
.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
|
|
424
|
+
.toLowerCase() +
|
|
425
|
+
" at " +
|
|
426
|
+
now.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }).toLowerCase();
|
|
427
|
+
|
|
428
|
+
const scanScope = existsSync(CONF) ? `config: ${shortPath(CONF)}` : "~/ (depth 5)";
|
|
429
|
+
|
|
430
|
+
// ── Lint Subcommand ──────────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
if (cliArgs.command === "lint") {
|
|
433
|
+
let totalIssues = 0;
|
|
434
|
+
for (const repo of configured) {
|
|
435
|
+
const issues = lintConfig(repo);
|
|
436
|
+
if (issues.length === 0) continue;
|
|
437
|
+
if (!cliArgs.quiet) console.log(`\n${repo.name} (${repo.shortPath}):`);
|
|
438
|
+
for (const issue of issues) {
|
|
439
|
+
if (!cliArgs.quiet)
|
|
440
|
+
console.log(` ${issue.level === "warn" ? "WARN" : "INFO"}: ${issue.message}`);
|
|
441
|
+
totalIssues++;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (!cliArgs.quiet) {
|
|
445
|
+
if (totalIssues === 0) console.log("No config issues found.");
|
|
446
|
+
else console.log(`\n${totalIssues} issue(s) found.`);
|
|
447
|
+
}
|
|
448
|
+
process.exit(totalIssues > 0 ? 1 : 0);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ── Dashboard Diff ───────────────────────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
const SNAPSHOT_PATH = join(CLAUDE_DIR, "dashboard-snapshot.json");
|
|
454
|
+
if (cliArgs.diff) {
|
|
455
|
+
const currentSnapshot = {
|
|
456
|
+
configuredRepos: configured.map((r) => ({ name: r.name, healthScore: r.healthScore || 0 })),
|
|
457
|
+
};
|
|
458
|
+
if (existsSync(SNAPSHOT_PATH)) {
|
|
459
|
+
try {
|
|
460
|
+
const prev = JSON.parse(readFileSync(SNAPSHOT_PATH, "utf8"));
|
|
461
|
+
const diff = computeDashboardDiff(prev, currentSnapshot);
|
|
462
|
+
if (!cliArgs.quiet) {
|
|
463
|
+
console.log("Dashboard diff since last generation:");
|
|
464
|
+
if (diff.added.length) console.log(` Added: ${diff.added.join(", ")}`);
|
|
465
|
+
if (diff.removed.length) console.log(` Removed: ${diff.removed.join(", ")}`);
|
|
466
|
+
for (const c of diff.changed) console.log(` ${c.name}: ${c.field} ${c.from} -> ${c.to}`);
|
|
467
|
+
if (!diff.added.length && !diff.removed.length && !diff.changed.length)
|
|
468
|
+
console.log(" No changes.");
|
|
469
|
+
}
|
|
470
|
+
} catch {
|
|
471
|
+
if (!cliArgs.quiet) console.log("Previous snapshot unreadable, saving new baseline.");
|
|
472
|
+
}
|
|
473
|
+
} else {
|
|
474
|
+
if (!cliArgs.quiet) console.log("No previous snapshot found, saving baseline.");
|
|
475
|
+
}
|
|
476
|
+
writeFileSync(SNAPSHOT_PATH, JSON.stringify(currentSnapshot, null, 2));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ── Anonymize ────────────────────────────────────────────────────────────────
|
|
480
|
+
|
|
481
|
+
if (cliArgs.anonymize) {
|
|
482
|
+
for (const repo of [...configured, ...unconfigured]) {
|
|
483
|
+
repo.shortPath = anonymizePath(repo.shortPath);
|
|
484
|
+
repo.path = anonymizePath(repo.path);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ── JSON Output ──────────────────────────────────────────────────────────────
|
|
489
|
+
|
|
490
|
+
if (cliArgs.json) {
|
|
491
|
+
const jsonData = {
|
|
492
|
+
version: VERSION,
|
|
493
|
+
generatedAt: now.toISOString(),
|
|
494
|
+
scanScope,
|
|
495
|
+
stats: {
|
|
496
|
+
totalRepos,
|
|
497
|
+
configuredRepos: configuredCount,
|
|
498
|
+
unconfiguredRepos: unconfiguredCount,
|
|
499
|
+
coveragePct,
|
|
500
|
+
globalCommands: globalCmds.length,
|
|
501
|
+
globalRules: globalRules.length,
|
|
502
|
+
skills: globalSkills.length,
|
|
503
|
+
repoCommands: totalRepoCmds,
|
|
504
|
+
avgHealthScore: avgHealth,
|
|
505
|
+
driftingRepos: driftCount,
|
|
506
|
+
mcpServers: mcpCount,
|
|
507
|
+
...(ccusageData
|
|
508
|
+
? {
|
|
509
|
+
totalCost: ccusageData.totals.totalCost,
|
|
510
|
+
totalTokens: ccusageData.totals.totalTokens,
|
|
511
|
+
}
|
|
512
|
+
: {}),
|
|
513
|
+
errorCategories: usageAnalytics.errorCategories,
|
|
514
|
+
},
|
|
515
|
+
globalCommands: globalCmds.map((c) => ({ name: c.name, description: c.desc })),
|
|
516
|
+
globalRules: globalRules.map((r) => ({ name: r.name, description: r.desc })),
|
|
517
|
+
skills: globalSkills.map((s) => ({
|
|
518
|
+
name: s.name,
|
|
519
|
+
description: s.desc,
|
|
520
|
+
source: s.source,
|
|
521
|
+
category: s.category,
|
|
522
|
+
})),
|
|
523
|
+
chains: chains.map((c) => ({
|
|
524
|
+
nodes: c.nodes.map((n) => n.trim()),
|
|
525
|
+
direction: c.arrow === "→" ? "forward" : "backward",
|
|
526
|
+
})),
|
|
527
|
+
configuredRepos: configured.map((r) => ({
|
|
528
|
+
name: r.name,
|
|
529
|
+
path: r.shortPath,
|
|
530
|
+
commands: r.commands.map((c) => ({ name: c.name, description: c.desc })),
|
|
531
|
+
rules: r.rules.map((ru) => ({ name: ru.name, description: ru.desc })),
|
|
532
|
+
sections: r.sections.map((s) => s.name),
|
|
533
|
+
description: r.desc,
|
|
534
|
+
techStack: r.techStack || [],
|
|
535
|
+
healthScore: r.healthScore || 0,
|
|
536
|
+
healthReasons: r.healthReasons || [],
|
|
537
|
+
freshness: {
|
|
538
|
+
timestamp: r.freshness,
|
|
539
|
+
relative: r.freshnessText,
|
|
540
|
+
class: r.freshnessClass,
|
|
541
|
+
},
|
|
542
|
+
drift: r.drift || { level: "unknown", commitsSince: 0 },
|
|
543
|
+
configPattern: r.configPattern || "minimal",
|
|
544
|
+
matchedSkills: r.matchedSkills || [],
|
|
545
|
+
similarRepos: r.similarRepos || [],
|
|
546
|
+
mcpServers: r.mcpServers || [],
|
|
547
|
+
})),
|
|
548
|
+
consolidationGroups,
|
|
549
|
+
unconfiguredRepos: unconfigured.map((r) => ({
|
|
550
|
+
name: r.name,
|
|
551
|
+
path: r.shortPath,
|
|
552
|
+
techStack: r.techStack || [],
|
|
553
|
+
suggestions: r.suggestions || [],
|
|
554
|
+
exemplar: r.exemplarName || "",
|
|
555
|
+
mcpServers: r.mcpServers || [],
|
|
556
|
+
})),
|
|
557
|
+
mcpServers: mcpSummary,
|
|
558
|
+
mcpPromotions,
|
|
559
|
+
formerMcpServers,
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
const jsonOutput = JSON.stringify(jsonData, null, 2);
|
|
563
|
+
|
|
564
|
+
if (cliArgs.output !== DEFAULT_OUTPUT) {
|
|
565
|
+
mkdirSync(dirname(cliArgs.output), { recursive: true });
|
|
566
|
+
writeFileSync(cliArgs.output, jsonOutput);
|
|
567
|
+
if (!cliArgs.quiet) console.log(cliArgs.output);
|
|
568
|
+
} else {
|
|
569
|
+
process.stdout.write(jsonOutput + "\n");
|
|
570
|
+
}
|
|
571
|
+
process.exit(0);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ── Catalog Output ───────────────────────────────────────────────────────────
|
|
575
|
+
|
|
576
|
+
if (cliArgs.catalog) {
|
|
577
|
+
const groups = groupSkillsByCategory(globalSkills);
|
|
578
|
+
const catalogHtml = generateCatalogHtml(groups, globalSkills.length, timestamp);
|
|
579
|
+
const outputPath =
|
|
580
|
+
cliArgs.output !== DEFAULT_OUTPUT ? cliArgs.output : join(CLAUDE_DIR, "skill-catalog.html");
|
|
581
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
582
|
+
writeFileSync(outputPath, catalogHtml);
|
|
583
|
+
if (!cliArgs.quiet) console.log(outputPath);
|
|
584
|
+
if (cliArgs.open) {
|
|
585
|
+
const cmd =
|
|
586
|
+
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
587
|
+
execFile(cmd, [outputPath]);
|
|
588
|
+
}
|
|
589
|
+
process.exit(0);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ── Generate HTML Dashboard ──────────────────────────────────────────────────
|
|
593
|
+
|
|
594
|
+
const html = generateDashboardHtml({
|
|
595
|
+
configured,
|
|
596
|
+
unconfigured,
|
|
597
|
+
globalCmds,
|
|
598
|
+
globalRules,
|
|
599
|
+
globalSkills,
|
|
600
|
+
chains,
|
|
601
|
+
mcpSummary,
|
|
602
|
+
mcpPromotions,
|
|
603
|
+
formerMcpServers,
|
|
604
|
+
consolidationGroups,
|
|
605
|
+
usageAnalytics,
|
|
606
|
+
ccusageData,
|
|
607
|
+
statsCache,
|
|
608
|
+
timestamp,
|
|
609
|
+
coveragePct,
|
|
610
|
+
totalRepos,
|
|
611
|
+
configuredCount,
|
|
612
|
+
unconfiguredCount,
|
|
613
|
+
totalRepoCmds,
|
|
614
|
+
avgHealth,
|
|
615
|
+
driftCount,
|
|
616
|
+
mcpCount,
|
|
617
|
+
scanScope,
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
// ── Write HTML Output ────────────────────────────────────────────────────────
|
|
621
|
+
|
|
622
|
+
const outputPath = cliArgs.output;
|
|
623
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
624
|
+
writeFileSync(outputPath, html);
|
|
625
|
+
if (!cliArgs.quiet) console.log(outputPath);
|
|
626
|
+
|
|
627
|
+
if (cliArgs.open) {
|
|
628
|
+
const cmd =
|
|
629
|
+
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
630
|
+
execFile(cmd, [outputPath]);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ── Watch Mode ───────────────────────────────────────────────────────────────
|
|
634
|
+
|
|
635
|
+
if (cliArgs.watch) {
|
|
636
|
+
startWatch(outputPath, scanRoots, cliArgs);
|
|
637
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@viren/claude-code-dashboard",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "A visual dashboard for your Claude Code configuration across all repos",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"claude-code-dashboard": "./generate-dashboard.mjs"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"generate": "node generate-dashboard.mjs",
|
|
11
|
+
"test": "node --test test/helpers.test.mjs",
|
|
12
|
+
"lint": "eslint .",
|
|
13
|
+
"lint:fix": "eslint . --fix",
|
|
14
|
+
"format": "prettier --write .",
|
|
15
|
+
"format:check": "prettier --check .",
|
|
16
|
+
"check": "npm run lint && npm run format:check && npm test"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18.0.0"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"claude",
|
|
23
|
+
"claude-code",
|
|
24
|
+
"dashboard",
|
|
25
|
+
"developer-tools",
|
|
26
|
+
"cli"
|
|
27
|
+
],
|
|
28
|
+
"author": "Viren Mohindra",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/VirenMohindra/claude-code-dashboard"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"generate-dashboard.mjs",
|
|
36
|
+
"src/"
|
|
37
|
+
],
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"eslint": "^9.0.0",
|
|
40
|
+
"prettier": "^3.0.0"
|
|
41
|
+
}
|
|
42
|
+
}
|