@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
package/src/markdown.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { BOILERPLATE_RE } from "./constants.mjs";
|
|
4
|
+
|
|
5
|
+
export function getDescFromContent(content) {
|
|
6
|
+
const lines = content.split("\n");
|
|
7
|
+
|
|
8
|
+
// YAML frontmatter
|
|
9
|
+
if (lines[0] === "---") {
|
|
10
|
+
for (let i = 1; i < lines.length; i++) {
|
|
11
|
+
if (lines[i] === "---") break;
|
|
12
|
+
const m = lines[i].match(/^description:\s*(.+)/);
|
|
13
|
+
if (m) return m[1].trim();
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (lines[0]?.startsWith("# ")) return lines[0].slice(2);
|
|
18
|
+
|
|
19
|
+
// First non-empty, non-frontmatter line
|
|
20
|
+
for (const l of lines.slice(0, 5)) {
|
|
21
|
+
const t = l.trim();
|
|
22
|
+
if (t && t !== "---" && !t.startsWith("```")) {
|
|
23
|
+
return t.length > 60 ? t.slice(0, 57) + "..." : t;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getDesc(filepath) {
|
|
30
|
+
try {
|
|
31
|
+
return getDescFromContent(readFileSync(filepath, "utf8"));
|
|
32
|
+
} catch {
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function extractProjectDesc(filepath) {
|
|
38
|
+
let lines;
|
|
39
|
+
try {
|
|
40
|
+
lines = readFileSync(filepath, "utf8").split("\n");
|
|
41
|
+
} catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const result = [];
|
|
46
|
+
let inCode = false;
|
|
47
|
+
let foundContent = false;
|
|
48
|
+
|
|
49
|
+
for (const line of lines) {
|
|
50
|
+
if (line.startsWith("```")) {
|
|
51
|
+
inCode = !inCode;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (inCode) continue;
|
|
55
|
+
if (line.startsWith("# ") && result.length === 0) continue;
|
|
56
|
+
if (!line.trim() && !foundContent) continue;
|
|
57
|
+
if (line.startsWith("## ") && foundContent) break;
|
|
58
|
+
if (line.startsWith("## ") && !foundContent) continue;
|
|
59
|
+
if (BOILERPLATE_RE.test(line)) continue;
|
|
60
|
+
if (/^[^#|`]/.test(line) && line.trim().length > 5) {
|
|
61
|
+
foundContent = true;
|
|
62
|
+
result.push(line.replace(/\*\*/g, "").replace(/`/g, ""));
|
|
63
|
+
if (result.length >= 2) break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function extractSections(filepath) {
|
|
70
|
+
let lines;
|
|
71
|
+
try {
|
|
72
|
+
lines = readFileSync(filepath, "utf8").split("\n");
|
|
73
|
+
} catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const sections = [];
|
|
78
|
+
let current = null;
|
|
79
|
+
let inCode = false;
|
|
80
|
+
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
if (line.startsWith("```")) {
|
|
83
|
+
inCode = !inCode;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (inCode) continue;
|
|
87
|
+
|
|
88
|
+
if (line.startsWith("## ")) {
|
|
89
|
+
current = { name: line.slice(3), preview: [] };
|
|
90
|
+
sections.push(current);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (current && current.preview.length < 3 && !line.startsWith("#") && line.trim()) {
|
|
94
|
+
let cleaned = line.replace(/\*\*/g, "").replace(/`/g, "").replace(/^- /, "");
|
|
95
|
+
if (cleaned.trim().length > 2) {
|
|
96
|
+
if (cleaned.length > 80) cleaned = cleaned.slice(0, 77) + "...";
|
|
97
|
+
current.preview.push(cleaned.trim());
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return sections;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function extractSteps(filepath) {
|
|
105
|
+
let lines;
|
|
106
|
+
try {
|
|
107
|
+
lines = readFileSync(filepath, "utf8").split("\n");
|
|
108
|
+
} catch {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const steps = [];
|
|
113
|
+
for (const line of lines) {
|
|
114
|
+
if (line.startsWith("## ")) {
|
|
115
|
+
steps.push({ type: "section", text: line.slice(3) });
|
|
116
|
+
} else if (/^\d+\. /.test(line)) {
|
|
117
|
+
steps.push({ type: "step", text: line.replace(/^\d+\. /, "").replace(/\*\*/g, "") });
|
|
118
|
+
} else if (line.startsWith("- **")) {
|
|
119
|
+
const m = line.match(/^- \*\*([^*]+)\*\*/);
|
|
120
|
+
if (m) steps.push({ type: "key", text: m[1] });
|
|
121
|
+
}
|
|
122
|
+
if (steps.length >= 12) break;
|
|
123
|
+
}
|
|
124
|
+
return steps;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function scanMdDir(dir) {
|
|
128
|
+
if (!existsSync(dir)) return [];
|
|
129
|
+
const results = [];
|
|
130
|
+
try {
|
|
131
|
+
for (const f of readdirSync(dir)) {
|
|
132
|
+
if (!f.endsWith(".md")) continue;
|
|
133
|
+
const full = join(dir, f);
|
|
134
|
+
const name = f.slice(0, -3);
|
|
135
|
+
const desc = getDesc(full);
|
|
136
|
+
results.push({ name, desc: desc || "No description", filepath: full });
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
/* directory unreadable */
|
|
140
|
+
}
|
|
141
|
+
return results.sort((a, b) => a.name.localeCompare(b.name));
|
|
142
|
+
}
|
package/src/mcp.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
export function parseUserMcpConfig(content) {
|
|
5
|
+
try {
|
|
6
|
+
const data = JSON.parse(content);
|
|
7
|
+
const servers = [];
|
|
8
|
+
const mcpServers = data.mcpServers || {};
|
|
9
|
+
for (const [name, cfg] of Object.entries(mcpServers)) {
|
|
10
|
+
const type = cfg.type || (cfg.command ? "stdio" : cfg.url ? "http" : "unknown");
|
|
11
|
+
servers.push({ name, type, scope: "user", source: "~/.claude/mcp_config.json" });
|
|
12
|
+
}
|
|
13
|
+
return servers;
|
|
14
|
+
} catch {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseProjectMcpConfig(content, repoPath) {
|
|
20
|
+
try {
|
|
21
|
+
const data = JSON.parse(content);
|
|
22
|
+
const servers = [];
|
|
23
|
+
const mcpServers = data.mcpServers || {};
|
|
24
|
+
for (const [name, cfg] of Object.entries(mcpServers)) {
|
|
25
|
+
const type = cfg.type || (cfg.command ? "stdio" : cfg.url ? "http" : "unknown");
|
|
26
|
+
servers.push({ name, type, scope: "project", source: repoPath });
|
|
27
|
+
}
|
|
28
|
+
return servers;
|
|
29
|
+
} catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function findPromotionCandidates(servers) {
|
|
35
|
+
const userLevel = new Set(servers.filter((s) => s.scope === "user").map((s) => s.name));
|
|
36
|
+
const projectServers = servers.filter((s) => s.scope === "project");
|
|
37
|
+
const byName = {};
|
|
38
|
+
for (const s of projectServers) {
|
|
39
|
+
if (userLevel.has(s.name)) continue;
|
|
40
|
+
if (!byName[s.name]) byName[s.name] = new Set();
|
|
41
|
+
byName[s.name].add(s.source);
|
|
42
|
+
}
|
|
43
|
+
return Object.entries(byName)
|
|
44
|
+
.filter(([, projects]) => projects.size >= 2)
|
|
45
|
+
.map(([name, projects]) => ({ name, projects: [...projects].sort() }))
|
|
46
|
+
.sort((a, b) => b.projects.length - a.projects.length || a.name.localeCompare(b.name));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function scanHistoricalMcpServers(claudeDir) {
|
|
50
|
+
const historical = new Set();
|
|
51
|
+
const fileHistoryDir = join(claudeDir, "file-history");
|
|
52
|
+
if (!existsSync(fileHistoryDir)) return [];
|
|
53
|
+
const MAX_SESSION_DIRS = 200;
|
|
54
|
+
const MAX_FILES_TOTAL = 1000;
|
|
55
|
+
let filesRead = 0;
|
|
56
|
+
try {
|
|
57
|
+
const sessionDirs = readdirSync(fileHistoryDir).sort().slice(-MAX_SESSION_DIRS);
|
|
58
|
+
for (const sessionDir of sessionDirs) {
|
|
59
|
+
if (filesRead >= MAX_FILES_TOTAL) break;
|
|
60
|
+
const sessionPath = join(fileHistoryDir, sessionDir);
|
|
61
|
+
if (!statSync(sessionPath).isDirectory()) continue;
|
|
62
|
+
try {
|
|
63
|
+
for (const snapFile of readdirSync(sessionPath)) {
|
|
64
|
+
if (filesRead >= MAX_FILES_TOTAL) break;
|
|
65
|
+
filesRead++;
|
|
66
|
+
const snapPath = join(sessionPath, snapFile);
|
|
67
|
+
try {
|
|
68
|
+
const content = readFileSync(snapPath, "utf8");
|
|
69
|
+
if (!content.includes("mcpServers")) continue;
|
|
70
|
+
const data = JSON.parse(content);
|
|
71
|
+
for (const name of Object.keys(data.mcpServers || {})) {
|
|
72
|
+
historical.add(name);
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
/* skip malformed */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
/* skip unreadable session dir */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
/* skip unreadable file-history dir */
|
|
84
|
+
}
|
|
85
|
+
return [...historical];
|
|
86
|
+
}
|
package/src/render.mjs
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { esc } from "./helpers.mjs";
|
|
2
|
+
import { extractSteps, extractSections } from "./markdown.mjs";
|
|
3
|
+
import { groupSkillsByCategory } from "./skills.mjs";
|
|
4
|
+
|
|
5
|
+
export function renderSections(sections) {
|
|
6
|
+
return sections
|
|
7
|
+
.map(
|
|
8
|
+
(s) =>
|
|
9
|
+
`<details class="agent-section"><summary>${esc(s.name)}</summary>` +
|
|
10
|
+
(s.preview.length
|
|
11
|
+
? `<div class="agent-section-preview">${s.preview.map((l) => `<div class="line">${esc(l)}</div>`).join("")}</div>`
|
|
12
|
+
: "") +
|
|
13
|
+
`</details>`,
|
|
14
|
+
)
|
|
15
|
+
.join("");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function renderCmd(cmd, prefix = "/") {
|
|
19
|
+
const steps = extractSteps(cmd.filepath);
|
|
20
|
+
const d = esc(cmd.desc);
|
|
21
|
+
if (steps.length) {
|
|
22
|
+
const body = steps.map((s) => `<div class="detail-${s.type}">${esc(s.text)}</div>`).join("");
|
|
23
|
+
return `<details class="cmd-detail"><summary><span class="cmd-name">${prefix}${esc(cmd.name)}</span><span class="cmd-desc">${d}</span></summary><div class="detail-body">${body}</div></details>`;
|
|
24
|
+
}
|
|
25
|
+
return `<div class="cmd-row"><span class="cmd-name">${prefix}${esc(cmd.name)}</span><span class="cmd-desc">${d}</span></div>`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function renderRule(rule) {
|
|
29
|
+
const sections = extractSections(rule.filepath);
|
|
30
|
+
const d = esc(rule.desc);
|
|
31
|
+
if (sections.length) {
|
|
32
|
+
return `<details class="cmd-detail"><summary><span class="cmd-name">${esc(rule.name)}</span><span class="cmd-desc">${d}</span></summary><div class="detail-body">${renderSections(sections)}</div></details>`;
|
|
33
|
+
}
|
|
34
|
+
return `<div class="cmd-row"><span class="cmd-name">${esc(rule.name)}</span><span class="cmd-desc">${d}</span></div>`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function sourceBadgeHtml(source) {
|
|
38
|
+
if (!source) return "";
|
|
39
|
+
switch (source.type) {
|
|
40
|
+
case "superpowers":
|
|
41
|
+
return `<span class="badge source superpowers">superpowers</span>`;
|
|
42
|
+
case "skills.sh": {
|
|
43
|
+
const label = source.repo ? `skills.sh · ${esc(source.repo)}` : "skills.sh";
|
|
44
|
+
return `<span class="badge source skillssh">${label}</span>`;
|
|
45
|
+
}
|
|
46
|
+
default:
|
|
47
|
+
return `<span class="badge source custom">custom</span>`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function renderSkill(skill) {
|
|
52
|
+
const sections = extractSections(skill.filepath);
|
|
53
|
+
const d = esc(skill.desc);
|
|
54
|
+
const badge = sourceBadgeHtml(skill.source);
|
|
55
|
+
if (sections.length) {
|
|
56
|
+
return `<details class="cmd-detail"><summary><span class="cmd-name skill-name">${esc(skill.name)}</span>${badge}<span class="cmd-desc">${d}</span></summary><div class="detail-body">${renderSections(sections)}</div></details>`;
|
|
57
|
+
}
|
|
58
|
+
return `<div class="cmd-row"><span class="cmd-name skill-name">${esc(skill.name)}</span>${badge}<span class="cmd-desc">${d}</span></div>`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Re-export from skills.mjs (single source of truth)
|
|
62
|
+
export { groupSkillsByCategory };
|
|
63
|
+
|
|
64
|
+
export function renderBadges(repo) {
|
|
65
|
+
const b = [];
|
|
66
|
+
if (repo.commands.length) b.push(`<span class="badge cmds">${repo.commands.length} cmd</span>`);
|
|
67
|
+
if (repo.rules.length) b.push(`<span class="badge rules">${repo.rules.length} rules</span>`);
|
|
68
|
+
if (repo.sections.length)
|
|
69
|
+
b.push(`<span class="badge agent">${repo.sections.length} sections</span>`);
|
|
70
|
+
if (repo.techStack && repo.techStack.length)
|
|
71
|
+
b.push(`<span class="badge stack">${esc(repo.techStack.join(", "))}</span>`);
|
|
72
|
+
return b.join("");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function healthScoreColor(score) {
|
|
76
|
+
if (score >= 80) return "var(--green)";
|
|
77
|
+
if (score >= 50) return "var(--yellow)";
|
|
78
|
+
return "var(--red)";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function renderHealthBar(repo) {
|
|
82
|
+
if (repo.healthScore === undefined) return "";
|
|
83
|
+
const s = Math.max(0, Math.min(100, repo.healthScore || 0));
|
|
84
|
+
const color = healthScoreColor(s);
|
|
85
|
+
return `<div class="health-bar"><div class="health-fill" style="width:${s}%;background:${color}"></div><span class="health-label">${s}</span></div>`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function renderDriftIndicator(repo) {
|
|
89
|
+
if (!repo.drift || repo.drift.level === "unknown" || repo.drift.level === "synced") return "";
|
|
90
|
+
const cls = `drift-${esc(repo.drift.level)}`;
|
|
91
|
+
const n = Number(repo.drift.commitsSince) || 0;
|
|
92
|
+
return `<span class="drift ${cls}" title="${n} commits since config update">${n}​Δ</span>`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function renderRepoCard(repo) {
|
|
96
|
+
const badges = renderBadges(repo);
|
|
97
|
+
const preview = repo.desc[0] ? esc(repo.desc[0].slice(0, 120)) : "";
|
|
98
|
+
const drift = renderDriftIndicator(repo);
|
|
99
|
+
|
|
100
|
+
let body = "";
|
|
101
|
+
|
|
102
|
+
body += `<div class="repo-meta"><span class="repo-path">${esc(repo.shortPath)}</span>`;
|
|
103
|
+
body += `<span class="freshness ${repo.freshnessClass}">${esc(repo.freshnessText)}${drift}</span></div>`;
|
|
104
|
+
|
|
105
|
+
body += renderHealthBar(repo);
|
|
106
|
+
|
|
107
|
+
if (repo.desc.length) {
|
|
108
|
+
body += `<div class="repo-desc">${repo.desc.map((l) => esc(l)).join("<br>")}</div>`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (repo.healthReasons && repo.healthReasons.length) {
|
|
112
|
+
body += `<div class="label">Quick Wins</div>`;
|
|
113
|
+
body += `<div class="quick-wins">${repo.healthReasons.map((r) => `<span class="quick-win">${esc(r)}</span>`).join("")}</div>`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (repo.commands.length) {
|
|
117
|
+
body += `<div class="label">Commands</div>`;
|
|
118
|
+
body += repo.commands.map((c) => renderCmd(c)).join("");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (repo.rules.length) {
|
|
122
|
+
body += `<div class="label">Rules</div>`;
|
|
123
|
+
body += repo.rules.map((r) => renderRule(r)).join("");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (repo.matchedSkills && repo.matchedSkills.length) {
|
|
127
|
+
body += `<div class="label">Relevant Skills</div>`;
|
|
128
|
+
body += `<div class="matched-skills">${repo.matchedSkills
|
|
129
|
+
.map((m) => `<span class="matched-skill">${esc(m.name)}</span>`)
|
|
130
|
+
.join("")}</div>`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (repo.sections.length) {
|
|
134
|
+
body += `<div class="label">Agent Config</div>`;
|
|
135
|
+
body += renderSections(repo.sections);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const parent = repo.shortPath.split("/").slice(0, -1).join("/");
|
|
139
|
+
|
|
140
|
+
return `<details class="repo-card" data-name="${esc(repo.name)}" data-path="${esc(repo.shortPath)}" data-stack="${esc((repo.techStack || []).join(","))}" data-parent="${esc(parent)}">
|
|
141
|
+
<summary>
|
|
142
|
+
<div class="repo-header">
|
|
143
|
+
<div class="repo-name">${esc(repo.name)}<span class="freshness-dot ${repo.freshnessClass}"></span></div>
|
|
144
|
+
</div>
|
|
145
|
+
${preview ? `<div class="repo-preview">${preview}</div>` : ""}
|
|
146
|
+
${badges ? `<div class="badges">${badges}</div>` : ""}
|
|
147
|
+
</summary>
|
|
148
|
+
<div class="repo-body">${body}</div>
|
|
149
|
+
</details>`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function generateCatalogHtml(groups, totalCount, ts) {
|
|
153
|
+
let cards = "";
|
|
154
|
+
for (const [cat, skills] of Object.entries(groups)) {
|
|
155
|
+
const heading = cat.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
156
|
+
let rows = "";
|
|
157
|
+
for (const s of skills) {
|
|
158
|
+
const badge = sourceBadgeHtml(s.source);
|
|
159
|
+
let hint = "";
|
|
160
|
+
if (s.source) {
|
|
161
|
+
switch (s.source.type) {
|
|
162
|
+
case "superpowers":
|
|
163
|
+
hint = "Included with superpowers-skills";
|
|
164
|
+
break;
|
|
165
|
+
case "skills.sh":
|
|
166
|
+
hint = s.source.repo
|
|
167
|
+
? `Installed via skills.sh (${esc(s.source.repo)})`
|
|
168
|
+
: "Installed via skills.sh";
|
|
169
|
+
break;
|
|
170
|
+
default:
|
|
171
|
+
hint = `Custom skill — copy from ~/.claude/skills/${esc(s.name)}/`;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
rows += `
|
|
175
|
+
<div class="cat-skill">
|
|
176
|
+
<div class="cat-skill-head">
|
|
177
|
+
<span class="cat-skill-name">${esc(s.name)}</span>${badge}
|
|
178
|
+
</div>
|
|
179
|
+
<div class="cat-skill-desc">${esc(s.desc)}</div>
|
|
180
|
+
${hint ? `<div class="cat-skill-hint">${hint}</div>` : ""}
|
|
181
|
+
</div>`;
|
|
182
|
+
}
|
|
183
|
+
cards += `
|
|
184
|
+
<section class="cat-group">
|
|
185
|
+
<h2>${esc(heading)} <span class="cat-n">${skills.length}</span></h2>
|
|
186
|
+
${rows}
|
|
187
|
+
</section>`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return `<!DOCTYPE html>
|
|
191
|
+
<html lang="en">
|
|
192
|
+
<head>
|
|
193
|
+
<meta charset="utf-8">
|
|
194
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
195
|
+
<title>Claude Code Skill Catalog</title>
|
|
196
|
+
<style>
|
|
197
|
+
:root {
|
|
198
|
+
--bg: #0a0a0a; --surface: #111; --surface2: #1a1a1a; --border: #262626;
|
|
199
|
+
--text: #e5e5e5; --text-dim: #777; --accent: #c4956a; --accent-dim: #8b6a4a;
|
|
200
|
+
--green: #4ade80; --blue: #60a5fa; --purple: #a78bfa; --yellow: #fbbf24;
|
|
201
|
+
--red: #f87171;
|
|
202
|
+
}
|
|
203
|
+
[data-theme="light"] {
|
|
204
|
+
--bg: #f5f5f5; --surface: #fff; --surface2: #f0f0f0; --border: #e0e0e0;
|
|
205
|
+
--text: #1a1a1a; --text-dim: #666; --accent: #9b6b47; --accent-dim: #b8956e;
|
|
206
|
+
--green: #16a34a; --blue: #2563eb; --purple: #7c3aed; --yellow: #ca8a04;
|
|
207
|
+
--red: #dc2626;
|
|
208
|
+
}
|
|
209
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
210
|
+
body {
|
|
211
|
+
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif;
|
|
212
|
+
background: var(--bg); color: var(--text);
|
|
213
|
+
padding: 2.5rem 2rem; line-height: 1.5; max-width: 900px; margin: 0 auto;
|
|
214
|
+
}
|
|
215
|
+
code { font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace; }
|
|
216
|
+
h1 { font-size: 1.4rem; font-weight: 600; color: var(--accent); margin-bottom: .2rem; }
|
|
217
|
+
.sub { color: var(--text-dim); font-size: .78rem; margin-bottom: 2rem; }
|
|
218
|
+
.cat-group { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 1.25rem; margin-bottom: 1.25rem; }
|
|
219
|
+
.cat-group 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; }
|
|
220
|
+
.cat-n { background: var(--surface2); border: 1px solid var(--border); border-radius: 4px; padding: .05rem .35rem; font-size: .65rem; color: var(--accent); }
|
|
221
|
+
.cat-skill { padding: .5rem .25rem; border-bottom: 1px solid var(--border); }
|
|
222
|
+
.cat-skill:last-child { border-bottom: none; }
|
|
223
|
+
.cat-skill-head { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
|
|
224
|
+
.cat-skill-name { font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace; font-weight: 600; color: var(--yellow); font-size: .82rem; }
|
|
225
|
+
.cat-skill-desc { color: var(--text-dim); font-size: .75rem; margin-top: .15rem; }
|
|
226
|
+
.cat-skill-hint { font-size: .65rem; color: var(--blue); margin-top: .2rem; opacity: .8; }
|
|
227
|
+
.badge { font-size: .55rem; padding: .1rem .35rem; border-radius: 3px; font-weight: 600; }
|
|
228
|
+
.badge.source.superpowers { background: rgba(167,139,250,.1); border: 1px solid rgba(167,139,250,.2); color: var(--purple); }
|
|
229
|
+
.badge.source.skillssh { background: rgba(96,165,250,.1); border: 1px solid rgba(96,165,250,.2); color: var(--blue); }
|
|
230
|
+
.badge.source.custom { background: rgba(251,191,36,.1); border: 1px solid rgba(251,191,36,.2); color: var(--yellow); }
|
|
231
|
+
@media (max-width: 600px) { body { padding: 1.5rem 1rem; } }
|
|
232
|
+
.theme-toggle {
|
|
233
|
+
position: fixed; top: 1rem; right: 1rem; z-index: 100;
|
|
234
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
|
235
|
+
padding: .4rem .6rem; cursor: pointer; color: var(--text-dim); font-size: .75rem;
|
|
236
|
+
transition: background .15s, border-color .15s;
|
|
237
|
+
}
|
|
238
|
+
.theme-toggle:hover { border-color: var(--accent-dim); }
|
|
239
|
+
.theme-icon::before { content: "\\263E"; }
|
|
240
|
+
[data-theme="light"] .theme-icon::before { content: "\\2600"; }
|
|
241
|
+
</style>
|
|
242
|
+
</head>
|
|
243
|
+
<body>
|
|
244
|
+
<h1>Claude Code Skill Catalog</h1>
|
|
245
|
+
<button id="theme-toggle" class="theme-toggle" title="Toggle light/dark mode" aria-label="Toggle theme"><span class="theme-icon"></span></button>
|
|
246
|
+
<div class="sub">${totalCount} skills · generated ${esc(ts)}</div>
|
|
247
|
+
${cards}
|
|
248
|
+
<script>
|
|
249
|
+
var toggle = document.getElementById('theme-toggle');
|
|
250
|
+
var saved = localStorage.getItem('ccd-theme');
|
|
251
|
+
if (saved) document.documentElement.setAttribute('data-theme', saved);
|
|
252
|
+
else if (window.matchMedia('(prefers-color-scheme: light)').matches) {
|
|
253
|
+
document.documentElement.setAttribute('data-theme', 'light');
|
|
254
|
+
}
|
|
255
|
+
toggle.addEventListener('click', function() {
|
|
256
|
+
var current = document.documentElement.getAttribute('data-theme');
|
|
257
|
+
var next = current === 'light' ? 'dark' : 'light';
|
|
258
|
+
document.documentElement.setAttribute('data-theme', next);
|
|
259
|
+
localStorage.setItem('ccd-theme', next);
|
|
260
|
+
});
|
|
261
|
+
</script>
|
|
262
|
+
</body>
|
|
263
|
+
</html>`;
|
|
264
|
+
}
|
package/src/skills.mjs
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, lstatSync, readlinkSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { HOME, SKILL_CATEGORIES, CATEGORY_ORDER } from "./constants.mjs";
|
|
4
|
+
import { gitCmd } from "./helpers.mjs";
|
|
5
|
+
import { getDescFromContent } from "./markdown.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Detect where a skill was sourced from:
|
|
9
|
+
* - "superpowers" — tracked in the obra/superpowers-skills git repo
|
|
10
|
+
* - "skills.sh" — installed via skills.sh, symlinked from ~/.agents/skills/
|
|
11
|
+
* - "custom" — user-created, not tracked by any known source
|
|
12
|
+
*/
|
|
13
|
+
export function detectSkillSource(skillName, skillsDir) {
|
|
14
|
+
const skillPath = join(skillsDir, skillName);
|
|
15
|
+
|
|
16
|
+
// 1. Check if it's a symlink → skills.sh
|
|
17
|
+
try {
|
|
18
|
+
const stat = lstatSync(skillPath);
|
|
19
|
+
if (stat.isSymbolicLink()) {
|
|
20
|
+
const target = readlinkSync(skillPath);
|
|
21
|
+
if (target.includes(".agents/skills") || target.includes(".agents\\skills")) {
|
|
22
|
+
// Try to read source info from skill-lock.json
|
|
23
|
+
const lockPath = join(HOME, ".agents", ".skill-lock.json");
|
|
24
|
+
if (existsSync(lockPath)) {
|
|
25
|
+
try {
|
|
26
|
+
const lock = JSON.parse(readFileSync(lockPath, "utf8"));
|
|
27
|
+
const entry = lock.skills?.[skillName];
|
|
28
|
+
if (entry) {
|
|
29
|
+
return {
|
|
30
|
+
type: "skills.sh",
|
|
31
|
+
repo: entry.source || "",
|
|
32
|
+
url: (entry.sourceUrl || "").replace(/\.git$/, ""),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
/* malformed JSON */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { type: "skills.sh" };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
/* not a symlink or unreadable */
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 2. Check if it comes from the git repo (e.g. obra/superpowers-skills)
|
|
47
|
+
if (existsSync(join(skillsDir, ".git"))) {
|
|
48
|
+
const remote = gitCmd(skillsDir, "remote", "get-url", "origin");
|
|
49
|
+
if (remote) {
|
|
50
|
+
const tracked = gitCmd(skillsDir, "ls-tree", "--name-only", "HEAD:skills/");
|
|
51
|
+
if (tracked) {
|
|
52
|
+
const trackedNames = new Set(tracked.split("\n").filter(Boolean));
|
|
53
|
+
if (trackedNames.has(skillName)) {
|
|
54
|
+
const repoSlug = remote.replace(/\.git$/, "").replace(/^https?:\/\/github\.com\//, "");
|
|
55
|
+
return {
|
|
56
|
+
type: "superpowers",
|
|
57
|
+
repo: repoSlug,
|
|
58
|
+
url: remote.replace(/\.git$/, ""),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 3. Fallback: custom
|
|
66
|
+
return { type: "custom" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Categorize a skill based on its name and description content. */
|
|
70
|
+
export function categorizeSkill(name, content) {
|
|
71
|
+
const nameLower = name.toLowerCase();
|
|
72
|
+
const contentLower = content.toLowerCase();
|
|
73
|
+
let bestCategory = "workflow";
|
|
74
|
+
let bestScore = 0;
|
|
75
|
+
|
|
76
|
+
for (const [category, keywords] of Object.entries(SKILL_CATEGORIES)) {
|
|
77
|
+
// Name matches get 3x weight since the name is the strongest signal
|
|
78
|
+
const nameScore = keywords.reduce((sum, kw) => sum + (nameLower.includes(kw) ? 3 : 0), 0);
|
|
79
|
+
const contentScore = keywords.reduce((sum, kw) => sum + (contentLower.includes(kw) ? 1 : 0), 0);
|
|
80
|
+
const score = nameScore + contentScore;
|
|
81
|
+
if (score > bestScore) {
|
|
82
|
+
bestScore = score;
|
|
83
|
+
bestCategory = category;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return bestCategory;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Scan ~/.claude/skills/ — each subdirectory with a SKILL.md is a skill. */
|
|
90
|
+
export function scanSkillsDir(dir) {
|
|
91
|
+
if (!existsSync(dir)) return [];
|
|
92
|
+
const results = [];
|
|
93
|
+
try {
|
|
94
|
+
for (const entry of readdirSync(dir)) {
|
|
95
|
+
const skillFile = join(dir, entry, "SKILL.md");
|
|
96
|
+
if (!existsSync(skillFile)) continue;
|
|
97
|
+
let content = "";
|
|
98
|
+
try {
|
|
99
|
+
content = readFileSync(skillFile, "utf8");
|
|
100
|
+
} catch {
|
|
101
|
+
/* unreadable */
|
|
102
|
+
}
|
|
103
|
+
const desc = getDescFromContent(content);
|
|
104
|
+
const source = detectSkillSource(entry, dir);
|
|
105
|
+
const category = categorizeSkill(entry, content);
|
|
106
|
+
results.push({
|
|
107
|
+
name: entry,
|
|
108
|
+
desc: desc || "No description",
|
|
109
|
+
filepath: skillFile,
|
|
110
|
+
source,
|
|
111
|
+
category,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
/* directory unreadable */
|
|
116
|
+
}
|
|
117
|
+
return results.sort((a, b) => a.name.localeCompare(b.name));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function groupSkillsByCategory(skills) {
|
|
121
|
+
const groups = {};
|
|
122
|
+
for (const cat of CATEGORY_ORDER) {
|
|
123
|
+
groups[cat] = [];
|
|
124
|
+
}
|
|
125
|
+
for (const skill of skills) {
|
|
126
|
+
const cat = skill.category || "workflow";
|
|
127
|
+
if (!groups[cat]) groups[cat] = [];
|
|
128
|
+
groups[cat].push(skill);
|
|
129
|
+
}
|
|
130
|
+
// Remove empty categories
|
|
131
|
+
for (const cat of Object.keys(groups)) {
|
|
132
|
+
if (groups[cat].length === 0) delete groups[cat];
|
|
133
|
+
}
|
|
134
|
+
return groups;
|
|
135
|
+
}
|