@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.
@@ -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: "&larr;" });
215
+ } else {
216
+ chains.push({ nodes: raw.split(/\s*->\s*/), arrow: "&rarr;" });
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 === "&rarr;" ? "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
+ }