@crafter/skillkit 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/package.json +24 -0
- package/src/bin.ts +77 -0
- package/src/commands/health.ts +128 -0
- package/src/commands/list.ts +46 -0
- package/src/commands/stats.ts +66 -0
- package/src/db/queries.ts +110 -0
- package/src/db/schema.ts +51 -0
- package/src/index.ts +19 -0
- package/src/scanner/index.ts +129 -0
- package/src/scanner/skills.ts +102 -0
- package/src/tui/bar.ts +20 -0
- package/src/tui/colors.ts +14 -0
- package/src/tui/health.ts +12 -0
- package/src/tui/sparkline.ts +17 -0
- package/src/types/index.ts +20 -0
- package/tsconfig.json +9 -0
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crafter/skillkit",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"skill-kit": "./src/bin.ts"
|
|
7
|
+
},
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"bun": "./src/index.ts",
|
|
11
|
+
"import": "./src/index.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"dev": "bun run src/bin.ts",
|
|
16
|
+
"build": "bun build src/bin.ts --compile --outfile dist/skill-kit",
|
|
17
|
+
"test": "bun test src/",
|
|
18
|
+
"type-check": "tsc --noEmit"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/bun": "^1.2.14",
|
|
22
|
+
"typescript": "^5.9.3"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/bin.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { bold, cyan, dim, yellow } from "./tui/colors";
|
|
3
|
+
|
|
4
|
+
const VERSION = "0.0.1";
|
|
5
|
+
|
|
6
|
+
function printHelp(): void {
|
|
7
|
+
console.log(`
|
|
8
|
+
${bold("skill-kit")} ${dim(`v${VERSION}`)} - Claude skill analytics & management
|
|
9
|
+
|
|
10
|
+
${bold("USAGE")}
|
|
11
|
+
skill-kit <command>
|
|
12
|
+
|
|
13
|
+
${bold("COMMANDS")}
|
|
14
|
+
${cyan("list")} List all installed skills
|
|
15
|
+
${cyan("stats")} Show usage analytics (last 30 days)
|
|
16
|
+
${cyan("health")} Run a health check on your skill setup
|
|
17
|
+
${cyan("analyze")} Scan sessions and populate analytics DB
|
|
18
|
+
${cyan("version")} Print version
|
|
19
|
+
${cyan("help")} Show this help message
|
|
20
|
+
`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function main(): Promise<void> {
|
|
24
|
+
const cmd = process.argv[2];
|
|
25
|
+
|
|
26
|
+
switch (cmd) {
|
|
27
|
+
case "list": {
|
|
28
|
+
const { runList } = await import("./commands/list");
|
|
29
|
+
runList();
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
case "stats": {
|
|
33
|
+
const { runStats } = await import("./commands/stats");
|
|
34
|
+
await runStats();
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
case "health": {
|
|
38
|
+
const { runHealth } = await import("./commands/health");
|
|
39
|
+
await runHealth();
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
case "analyze": {
|
|
43
|
+
const { getDb } = await import("./db/schema");
|
|
44
|
+
const { scanAllSessions } = await import("./scanner/index");
|
|
45
|
+
const db = getDb();
|
|
46
|
+
console.log("\n Scanning ~/.claude/projects/ for skill invocations...");
|
|
47
|
+
const count = await scanAllSessions(db);
|
|
48
|
+
console.log(
|
|
49
|
+
` ${count > 0 ? `Found ${count} new invocations.` : "No new invocations found."}\n`,
|
|
50
|
+
);
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
case "version":
|
|
54
|
+
case "--version":
|
|
55
|
+
case "-v": {
|
|
56
|
+
console.log(VERSION);
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
case "help":
|
|
60
|
+
case "--help":
|
|
61
|
+
case "-h":
|
|
62
|
+
case undefined: {
|
|
63
|
+
printHelp();
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
default: {
|
|
67
|
+
console.error(`\n ${yellow(`Unknown command: ${cmd}`)}`);
|
|
68
|
+
printHelp();
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
main().catch((err) => {
|
|
75
|
+
console.error(err);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { getTopSkills } from "../db/queries";
|
|
5
|
+
import { getDb } from "../db/schema";
|
|
6
|
+
import { scanInstalledSkills } from "../scanner/skills";
|
|
7
|
+
import { bold, dim, green, yellow } from "../tui/colors";
|
|
8
|
+
import { healthGauge } from "../tui/health";
|
|
9
|
+
|
|
10
|
+
const CONTEXT_BUDGET = 16000;
|
|
11
|
+
|
|
12
|
+
function check(label: string) {
|
|
13
|
+
return ` ${green("✓")} ${label}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function warn(label: string) {
|
|
17
|
+
return ` ${yellow("⚠")} ${label}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function info(label: string) {
|
|
21
|
+
return ` ${dim("●")} ${label}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function _formatSize(bytes: number): string {
|
|
25
|
+
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
26
|
+
return `${bytes} B`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function runHealth(): Promise<void> {
|
|
30
|
+
const skills = scanInstalledSkills();
|
|
31
|
+
const _skillsDir = join(homedir(), ".claude", "skills");
|
|
32
|
+
const dbPath = join(homedir(), ".skill-kit", "analytics.db");
|
|
33
|
+
const dbExists = existsSync(dbPath);
|
|
34
|
+
|
|
35
|
+
let totalContextChars = 0;
|
|
36
|
+
for (const skill of skills) {
|
|
37
|
+
const skillMdPath = join(skill.path, "SKILL.md");
|
|
38
|
+
if (existsSync(skillMdPath)) {
|
|
39
|
+
try {
|
|
40
|
+
const content = readFileSync(skillMdPath, "utf-8");
|
|
41
|
+
totalContextChars += content.length;
|
|
42
|
+
} catch {}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let eventCount = 0;
|
|
47
|
+
let neverUsed: string[] = [];
|
|
48
|
+
let hasDbData = false;
|
|
49
|
+
|
|
50
|
+
if (dbExists) {
|
|
51
|
+
const db = getDb();
|
|
52
|
+
const row = db
|
|
53
|
+
.query<{ count: number }, []>(
|
|
54
|
+
"SELECT COUNT(*) as count FROM skill_invocations",
|
|
55
|
+
)
|
|
56
|
+
.get();
|
|
57
|
+
eventCount = row?.count ?? 0;
|
|
58
|
+
hasDbData = eventCount > 0;
|
|
59
|
+
|
|
60
|
+
if (hasDbData) {
|
|
61
|
+
const topSkills = getTopSkills(db, 365);
|
|
62
|
+
const usedNames = new Set(topSkills.map((s) => s.skill_name));
|
|
63
|
+
neverUsed = skills
|
|
64
|
+
.filter((s) => !usedNames.has(s.name))
|
|
65
|
+
.map((s) => s.name);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const contextPct = Math.min(
|
|
70
|
+
100,
|
|
71
|
+
Math.round((totalContextChars / CONTEXT_BUDGET) * 100),
|
|
72
|
+
);
|
|
73
|
+
const usedContextKb = (totalContextChars / 1000).toFixed(1);
|
|
74
|
+
const budgetKb = (CONTEXT_BUDGET / 1000).toFixed(1);
|
|
75
|
+
|
|
76
|
+
console.log(`\n ${bold("SKILL-KIT HEALTH REPORT")}\n`);
|
|
77
|
+
|
|
78
|
+
console.log(check(`${skills.length} skills installed`));
|
|
79
|
+
|
|
80
|
+
if (dbExists && hasDbData) {
|
|
81
|
+
console.log(
|
|
82
|
+
check(`Analytics DB: ${eventCount.toLocaleString()} events tracked`),
|
|
83
|
+
);
|
|
84
|
+
} else if (dbExists) {
|
|
85
|
+
console.log(warn("Analytics DB exists but has no data"));
|
|
86
|
+
console.log(dim(" Run: skill-kit stats"));
|
|
87
|
+
} else {
|
|
88
|
+
console.log(warn("Analytics DB not found"));
|
|
89
|
+
console.log(dim(" Run: skill-kit stats"));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (neverUsed.length > 0) {
|
|
93
|
+
console.log();
|
|
94
|
+
console.log(warn(`${neverUsed.length} skills never used`));
|
|
95
|
+
const preview = neverUsed.slice(0, 5).join(", ");
|
|
96
|
+
const more = neverUsed.length > 5 ? ` +${neverUsed.length - 5} more` : "";
|
|
97
|
+
console.log(` ${dim(preview + more)}`);
|
|
98
|
+
console.log(` ${dim("Run: skill-kit uninstall <name>")}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const unusedContextChars = neverUsed.reduce((acc, name) => {
|
|
102
|
+
const skill = skills.find((s) => s.name === name);
|
|
103
|
+
if (!skill) return acc;
|
|
104
|
+
const skillMdPath = join(skill.path, "SKILL.md");
|
|
105
|
+
if (!existsSync(skillMdPath)) return acc;
|
|
106
|
+
try {
|
|
107
|
+
return acc + readFileSync(skillMdPath, "utf-8").length;
|
|
108
|
+
} catch {
|
|
109
|
+
return acc;
|
|
110
|
+
}
|
|
111
|
+
}, 0);
|
|
112
|
+
|
|
113
|
+
console.log();
|
|
114
|
+
const gaugeStr = healthGauge(100 - contextPct);
|
|
115
|
+
console.log(
|
|
116
|
+
info(
|
|
117
|
+
`Context budget: ${contextPct}% consumed (${usedContextKb}K / ${budgetKb}K chars)`,
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
console.log(` ${gaugeStr}`);
|
|
121
|
+
if (unusedContextChars > 0) {
|
|
122
|
+
console.log(
|
|
123
|
+
` ${dim(`${neverUsed.length} unused skills waste ~${(unusedContextChars / 1000).toFixed(1)}K chars`)}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log();
|
|
128
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { scanInstalledSkills } from "../scanner/skills";
|
|
2
|
+
import { bold, cyan, dim } from "../tui/colors";
|
|
3
|
+
|
|
4
|
+
function formatSize(bytes: number): string {
|
|
5
|
+
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
6
|
+
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
7
|
+
return `${bytes} B`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function truncate(s: string, max: number): string {
|
|
11
|
+
if (s.length <= max) return s;
|
|
12
|
+
return `${s.slice(0, max - 3)}...`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function runList(): void {
|
|
16
|
+
const skills = scanInstalledSkills();
|
|
17
|
+
|
|
18
|
+
if (skills.length === 0) {
|
|
19
|
+
console.log("\n No skills installed in ~/.claude/skills/\n");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const totalSize = skills.reduce((acc, s) => acc + s.size, 0);
|
|
24
|
+
|
|
25
|
+
console.log(`\n ${bold(`INSTALLED SKILLS (${skills.length})`)}\n`);
|
|
26
|
+
|
|
27
|
+
const nameWidth = 24;
|
|
28
|
+
const descWidth = 42;
|
|
29
|
+
const sizeWidth = 10;
|
|
30
|
+
|
|
31
|
+
const header = ` ${"NAME".padEnd(nameWidth)}${"DESCRIPTION".padEnd(descWidth)}${"SIZE".padStart(sizeWidth)}`;
|
|
32
|
+
console.log(dim(header));
|
|
33
|
+
|
|
34
|
+
for (const skill of skills) {
|
|
35
|
+
const name = cyan(skill.name.padEnd(nameWidth));
|
|
36
|
+
const desc = dim(
|
|
37
|
+
truncate(skill.description || "", descWidth).padEnd(descWidth),
|
|
38
|
+
);
|
|
39
|
+
const size = formatSize(skill.size).padStart(sizeWidth);
|
|
40
|
+
console.log(` ${name}${desc}${size}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
console.log(
|
|
44
|
+
`\n ${dim(`Total: ${skills.length} skills | ${formatSize(totalSize)} context budget`)}\n`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { getDailyUsage, getSkillStats, getTopSkills } from "../db/queries";
|
|
2
|
+
import { getDb } from "../db/schema";
|
|
3
|
+
import { scanAllSessions } from "../scanner/index";
|
|
4
|
+
import { bold, cyan, dim, yellow } from "../tui/colors";
|
|
5
|
+
import { sparkline } from "../tui/sparkline";
|
|
6
|
+
|
|
7
|
+
function getMostActiveDay(db: ReturnType<typeof getDb>): string {
|
|
8
|
+
const row = db
|
|
9
|
+
.query<{ day: string; count: number }, []>(
|
|
10
|
+
"SELECT strftime('%w', timestamp) as day, COUNT(*) as count FROM skill_invocations GROUP BY day ORDER BY count DESC LIMIT 1",
|
|
11
|
+
)
|
|
12
|
+
.get();
|
|
13
|
+
if (!row) return "N/A";
|
|
14
|
+
const days = [
|
|
15
|
+
"Sunday",
|
|
16
|
+
"Monday",
|
|
17
|
+
"Tuesday",
|
|
18
|
+
"Wednesday",
|
|
19
|
+
"Thursday",
|
|
20
|
+
"Friday",
|
|
21
|
+
"Saturday",
|
|
22
|
+
];
|
|
23
|
+
return days[parseInt(row.day, 10)] ?? "N/A";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function runStats(): Promise<void> {
|
|
27
|
+
const db = getDb();
|
|
28
|
+
console.log("\n Scanning sessions...");
|
|
29
|
+
const newCount = await scanAllSessions(db);
|
|
30
|
+
if (newCount > 0) {
|
|
31
|
+
console.log(` Found ${newCount} new invocations.\n`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const stats = getSkillStats(db, 30);
|
|
35
|
+
|
|
36
|
+
if (stats.total === 0) {
|
|
37
|
+
console.log(`\n ${yellow("No analytics data yet.")}`);
|
|
38
|
+
console.log(` ${dim("Run: skill-kit analyze")}\n`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const topSkills = getTopSkills(db, 30);
|
|
43
|
+
const activeDay = getMostActiveDay(db);
|
|
44
|
+
|
|
45
|
+
console.log(`\n ${bold("SKILL-KIT ANALYTICS")} ${dim("(last 30 days)")}\n`);
|
|
46
|
+
console.log(` Total invocations: ${bold(String(stats.total))}`);
|
|
47
|
+
console.log(` Unique skills: ${bold(String(stats.unique_skills))}`);
|
|
48
|
+
console.log(` Most active day: ${bold(activeDay)}\n`);
|
|
49
|
+
console.log(` ${bold("TOP SKILLS")}\n`);
|
|
50
|
+
|
|
51
|
+
const maxCount = topSkills.length > 0 ? (topSkills[0]?.total ?? 1) : 1;
|
|
52
|
+
const barWidth = 20;
|
|
53
|
+
|
|
54
|
+
for (const skill of topSkills.slice(0, 10)) {
|
|
55
|
+
const daily = getDailyUsage(db, skill.skill_name, 30);
|
|
56
|
+
const filled = Math.round((skill.total / maxCount) * barWidth);
|
|
57
|
+
const bar = "█".repeat(filled);
|
|
58
|
+
const spark = sparkline(daily.map((d) => d.count));
|
|
59
|
+
const name = cyan(skill.skill_name.padEnd(16));
|
|
60
|
+
console.log(
|
|
61
|
+
` ${name} ${bar.padEnd(barWidth)} ${String(skill.total).padStart(4)} ${spark}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log();
|
|
66
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
interface TopSkillRow {
|
|
4
|
+
skill_name: string;
|
|
5
|
+
total: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface StatsRow {
|
|
9
|
+
total: number;
|
|
10
|
+
unique_skills: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface DailyRow {
|
|
14
|
+
date: string;
|
|
15
|
+
count: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface InstalledSkillRow {
|
|
19
|
+
name: string;
|
|
20
|
+
path: string;
|
|
21
|
+
installed_at: string;
|
|
22
|
+
source: string | null;
|
|
23
|
+
version: string | null;
|
|
24
|
+
size_bytes: number | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getTopSkills(db: Database, days = 30): TopSkillRow[] {
|
|
28
|
+
const cutoff = new Date(
|
|
29
|
+
Date.now() - days * 24 * 60 * 60 * 1000,
|
|
30
|
+
).toISOString();
|
|
31
|
+
return db
|
|
32
|
+
.query<TopSkillRow, [string]>(
|
|
33
|
+
"SELECT skill_name, COUNT(*) as total FROM skill_invocations WHERE timestamp >= ? GROUP BY skill_name ORDER BY total DESC LIMIT 20",
|
|
34
|
+
)
|
|
35
|
+
.all(cutoff);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getSkillStats(db: Database, days = 30): StatsRow {
|
|
39
|
+
const cutoff = new Date(
|
|
40
|
+
Date.now() - days * 24 * 60 * 60 * 1000,
|
|
41
|
+
).toISOString();
|
|
42
|
+
return (
|
|
43
|
+
db
|
|
44
|
+
.query<StatsRow, [string]>(
|
|
45
|
+
"SELECT COUNT(*) as total, COUNT(DISTINCT skill_name) as unique_skills FROM skill_invocations WHERE timestamp >= ?",
|
|
46
|
+
)
|
|
47
|
+
.get(cutoff) ?? { total: 0, unique_skills: 0 }
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getDailyUsage(
|
|
52
|
+
db: Database,
|
|
53
|
+
skillName: string,
|
|
54
|
+
days = 30,
|
|
55
|
+
): DailyRow[] {
|
|
56
|
+
const cutoff = new Date(
|
|
57
|
+
Date.now() - days * 24 * 60 * 60 * 1000,
|
|
58
|
+
).toISOString();
|
|
59
|
+
return db
|
|
60
|
+
.query<DailyRow, [string, string]>(
|
|
61
|
+
"SELECT date(timestamp) as date, COUNT(*) as count FROM skill_invocations WHERE skill_name = ? AND timestamp >= ? GROUP BY date(timestamp) ORDER BY date ASC",
|
|
62
|
+
)
|
|
63
|
+
.all(skillName, cutoff);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getInstalledSkills(db: Database): InstalledSkillRow[] {
|
|
67
|
+
return db
|
|
68
|
+
.query<InstalledSkillRow, []>(
|
|
69
|
+
"SELECT * FROM installed_skills ORDER BY name ASC",
|
|
70
|
+
)
|
|
71
|
+
.all();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function recordInvocation(
|
|
75
|
+
db: Database,
|
|
76
|
+
skillName: string,
|
|
77
|
+
sessionId?: string,
|
|
78
|
+
project?: string,
|
|
79
|
+
): void {
|
|
80
|
+
db.run(
|
|
81
|
+
"INSERT INTO skill_invocations (skill_name, timestamp, session_id, project) VALUES (?, ?, ?, ?)",
|
|
82
|
+
[skillName, new Date().toISOString(), sessionId ?? null, project ?? null],
|
|
83
|
+
);
|
|
84
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
85
|
+
db.run(
|
|
86
|
+
"INSERT INTO skill_daily_stats (date, skill_name, count) VALUES (?, ?, 1) ON CONFLICT(date, skill_name) DO UPDATE SET count = count + 1",
|
|
87
|
+
[date, skillName],
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function upsertInstalledSkill(
|
|
92
|
+
db: Database,
|
|
93
|
+
name: string,
|
|
94
|
+
path: string,
|
|
95
|
+
source?: string,
|
|
96
|
+
version?: string,
|
|
97
|
+
sizeBytes?: number,
|
|
98
|
+
): void {
|
|
99
|
+
db.run(
|
|
100
|
+
"INSERT INTO installed_skills (name, path, installed_at, source, version, size_bytes) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(name) DO UPDATE SET path = excluded.path, source = excluded.source, version = excluded.version, size_bytes = excluded.size_bytes",
|
|
101
|
+
[
|
|
102
|
+
name,
|
|
103
|
+
path,
|
|
104
|
+
new Date().toISOString(),
|
|
105
|
+
source ?? null,
|
|
106
|
+
version ?? null,
|
|
107
|
+
sizeBytes ?? null,
|
|
108
|
+
],
|
|
109
|
+
);
|
|
110
|
+
}
|
package/src/db/schema.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const DB_DIR = join(homedir(), ".skill-kit");
|
|
7
|
+
const DB_PATH = join(DB_DIR, "analytics.db");
|
|
8
|
+
|
|
9
|
+
export function getDb(): Database {
|
|
10
|
+
mkdirSync(DB_DIR, { recursive: true });
|
|
11
|
+
const db = new Database(DB_PATH);
|
|
12
|
+
db.run("PRAGMA journal_mode=WAL");
|
|
13
|
+
db.run(`
|
|
14
|
+
CREATE TABLE IF NOT EXISTS skill_invocations (
|
|
15
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
16
|
+
skill_name TEXT NOT NULL,
|
|
17
|
+
timestamp TEXT NOT NULL,
|
|
18
|
+
session_id TEXT,
|
|
19
|
+
project TEXT,
|
|
20
|
+
success INTEGER DEFAULT 1
|
|
21
|
+
)
|
|
22
|
+
`);
|
|
23
|
+
db.run(`
|
|
24
|
+
CREATE TABLE IF NOT EXISTS skill_daily_stats (
|
|
25
|
+
date TEXT NOT NULL,
|
|
26
|
+
skill_name TEXT NOT NULL,
|
|
27
|
+
count INTEGER DEFAULT 0,
|
|
28
|
+
UNIQUE(date, skill_name)
|
|
29
|
+
)
|
|
30
|
+
`);
|
|
31
|
+
db.run(`
|
|
32
|
+
CREATE TABLE IF NOT EXISTS installed_skills (
|
|
33
|
+
name TEXT PRIMARY KEY,
|
|
34
|
+
path TEXT NOT NULL,
|
|
35
|
+
installed_at TEXT NOT NULL,
|
|
36
|
+
source TEXT,
|
|
37
|
+
version TEXT,
|
|
38
|
+
size_bytes INTEGER
|
|
39
|
+
)
|
|
40
|
+
`);
|
|
41
|
+
db.run(
|
|
42
|
+
"CREATE INDEX IF NOT EXISTS idx_invocations_skill ON skill_invocations(skill_name)",
|
|
43
|
+
);
|
|
44
|
+
db.run(
|
|
45
|
+
"CREATE INDEX IF NOT EXISTS idx_invocations_ts ON skill_invocations(timestamp DESC)",
|
|
46
|
+
);
|
|
47
|
+
db.run(
|
|
48
|
+
"CREATE INDEX IF NOT EXISTS idx_daily_date ON skill_daily_stats(date DESC)",
|
|
49
|
+
);
|
|
50
|
+
return db;
|
|
51
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { runHealth } from "./commands/health";
|
|
2
|
+
export { runList } from "./commands/list";
|
|
3
|
+
export { runStats } from "./commands/stats";
|
|
4
|
+
export {
|
|
5
|
+
getDailyUsage,
|
|
6
|
+
getInstalledSkills,
|
|
7
|
+
getSkillStats,
|
|
8
|
+
getTopSkills,
|
|
9
|
+
recordInvocation,
|
|
10
|
+
upsertInstalledSkill,
|
|
11
|
+
} from "./db/queries";
|
|
12
|
+
export { getDb } from "./db/schema";
|
|
13
|
+
export { parseSessionFile, scanAllSessions } from "./scanner/index";
|
|
14
|
+
export { scanInstalledSkills } from "./scanner/skills";
|
|
15
|
+
export type {
|
|
16
|
+
InstalledSkill,
|
|
17
|
+
SkillInvocation,
|
|
18
|
+
SkillStats,
|
|
19
|
+
} from "./types/index";
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
import { recordInvocation } from "../db/queries";
|
|
6
|
+
|
|
7
|
+
interface ToolUseBlock {
|
|
8
|
+
type: "tool_use";
|
|
9
|
+
name: string;
|
|
10
|
+
input: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface AssistantMessage {
|
|
14
|
+
type: "assistant";
|
|
15
|
+
message: {
|
|
16
|
+
content: Array<ToolUseBlock | { type: string }>;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function extractSessionId(filePath: string): string {
|
|
21
|
+
return basename(filePath, ".jsonl");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function extractSkillName(block: ToolUseBlock): string | null {
|
|
25
|
+
const input = block.input;
|
|
26
|
+
if (typeof input.skill === "string") return input.skill;
|
|
27
|
+
if (typeof input.name === "string") return input.name;
|
|
28
|
+
if (typeof input.skillName === "string") return input.skillName;
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function parseSessionFile(
|
|
33
|
+
filePath: string,
|
|
34
|
+
): Array<{ skillName: string; timestamp: string; sessionId: string }> {
|
|
35
|
+
const results: Array<{
|
|
36
|
+
skillName: string;
|
|
37
|
+
timestamp: string;
|
|
38
|
+
sessionId: string;
|
|
39
|
+
}> = [];
|
|
40
|
+
const sessionId = extractSessionId(filePath);
|
|
41
|
+
|
|
42
|
+
let content: string;
|
|
43
|
+
try {
|
|
44
|
+
content = readFileSync(filePath, "utf-8");
|
|
45
|
+
} catch {
|
|
46
|
+
return results;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const lines = content.split("\n");
|
|
50
|
+
for (const line of lines) {
|
|
51
|
+
if (!line.trim()) continue;
|
|
52
|
+
let entry: unknown;
|
|
53
|
+
try {
|
|
54
|
+
entry = JSON.parse(line);
|
|
55
|
+
} catch {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
60
|
+
const obj = entry as Record<string, unknown>;
|
|
61
|
+
|
|
62
|
+
const timestamp =
|
|
63
|
+
typeof obj.timestamp === "string"
|
|
64
|
+
? obj.timestamp
|
|
65
|
+
: new Date().toISOString();
|
|
66
|
+
|
|
67
|
+
const msg = obj.message as AssistantMessage["message"] | undefined;
|
|
68
|
+
const content = obj.type === "assistant" && msg ? msg.content : null;
|
|
69
|
+
|
|
70
|
+
if (!Array.isArray(content)) continue;
|
|
71
|
+
|
|
72
|
+
for (const block of content) {
|
|
73
|
+
if (
|
|
74
|
+
typeof block === "object" &&
|
|
75
|
+
block !== null &&
|
|
76
|
+
(block as Record<string, unknown>).type === "tool_use" &&
|
|
77
|
+
(block as ToolUseBlock).name === "Skill"
|
|
78
|
+
) {
|
|
79
|
+
const skillName = extractSkillName(block as ToolUseBlock);
|
|
80
|
+
if (skillName) {
|
|
81
|
+
results.push({ skillName, timestamp, sessionId });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return results;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface AlreadyTracked {
|
|
91
|
+
session_id: string;
|
|
92
|
+
timestamp: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function scanAllSessions(db: Database): Promise<number> {
|
|
96
|
+
const projectsDir = join(homedir(), ".claude", "projects");
|
|
97
|
+
if (!existsSync(projectsDir)) return 0;
|
|
98
|
+
|
|
99
|
+
const glob = new Bun.Glob("**/*.jsonl");
|
|
100
|
+
const files: string[] = [];
|
|
101
|
+
|
|
102
|
+
for await (const file of glob.scan({ cwd: projectsDir, absolute: true })) {
|
|
103
|
+
files.push(file);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const tracked = db
|
|
107
|
+
.query<AlreadyTracked, []>(
|
|
108
|
+
"SELECT session_id, timestamp FROM skill_invocations WHERE session_id IS NOT NULL",
|
|
109
|
+
)
|
|
110
|
+
.all();
|
|
111
|
+
const trackedSet = new Set(
|
|
112
|
+
tracked.map((r) => `${r.session_id}::${r.timestamp}`),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
let newCount = 0;
|
|
116
|
+
for (const file of files) {
|
|
117
|
+
const invocations = parseSessionFile(file);
|
|
118
|
+
for (const inv of invocations) {
|
|
119
|
+
const key = `${inv.sessionId}::${inv.timestamp}`;
|
|
120
|
+
if (!trackedSet.has(key)) {
|
|
121
|
+
recordInvocation(db, inv.skillName, inv.sessionId);
|
|
122
|
+
trackedSet.add(key);
|
|
123
|
+
newCount++;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return newCount;
|
|
129
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { InstalledSkill } from "../types";
|
|
5
|
+
|
|
6
|
+
function parseYamlFrontmatter(content: string): Record<string, string> {
|
|
7
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
8
|
+
if (!match || !match[1]) return {};
|
|
9
|
+
const result: Record<string, string> = {};
|
|
10
|
+
for (const line of match[1].split("\n")) {
|
|
11
|
+
const colonIdx = line.indexOf(":");
|
|
12
|
+
if (colonIdx === -1) continue;
|
|
13
|
+
const key = line.slice(0, colonIdx).trim();
|
|
14
|
+
const value = line
|
|
15
|
+
.slice(colonIdx + 1)
|
|
16
|
+
.trim()
|
|
17
|
+
.replace(/^["']|["']$/g, "");
|
|
18
|
+
if (key) result[key] = value;
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getDirSize(dirPath: string): number {
|
|
24
|
+
let total = 0;
|
|
25
|
+
try {
|
|
26
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
const fullPath = join(dirPath, entry.name);
|
|
29
|
+
if (entry.isDirectory()) {
|
|
30
|
+
total += getDirSize(fullPath);
|
|
31
|
+
} else {
|
|
32
|
+
try {
|
|
33
|
+
total += statSync(fullPath).size;
|
|
34
|
+
} catch {}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
} catch {}
|
|
38
|
+
return total;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function scanInstalledSkills(): InstalledSkill[] {
|
|
42
|
+
const skillsDir = join(homedir(), ".claude", "skills");
|
|
43
|
+
if (!existsSync(skillsDir)) return [];
|
|
44
|
+
|
|
45
|
+
const skills: InstalledSkill[] = [];
|
|
46
|
+
|
|
47
|
+
let entries: string[];
|
|
48
|
+
try {
|
|
49
|
+
entries = readdirSync(skillsDir);
|
|
50
|
+
} catch {
|
|
51
|
+
return skills;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
const skillPath = join(skillsDir, entry);
|
|
56
|
+
let stat: ReturnType<typeof statSync>;
|
|
57
|
+
try {
|
|
58
|
+
stat = statSync(skillPath);
|
|
59
|
+
} catch {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (!stat.isDirectory()) continue;
|
|
63
|
+
|
|
64
|
+
const skillMdPath = join(skillPath, "SKILL.md");
|
|
65
|
+
let description = "";
|
|
66
|
+
let name = entry;
|
|
67
|
+
|
|
68
|
+
if (existsSync(skillMdPath)) {
|
|
69
|
+
try {
|
|
70
|
+
const content = readFileSync(skillMdPath, "utf-8");
|
|
71
|
+
const frontmatter = parseYamlFrontmatter(content);
|
|
72
|
+
if (frontmatter.name) name = frontmatter.name;
|
|
73
|
+
if (frontmatter.description) description = frontmatter.description;
|
|
74
|
+
if (!description) {
|
|
75
|
+
const lines = content
|
|
76
|
+
.replace(/^---[\s\S]*?---\n/, "")
|
|
77
|
+
.trim()
|
|
78
|
+
.split("\n");
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
const cleaned = line.replace(/^#+\s*/, "").trim();
|
|
81
|
+
if (cleaned && !cleaned.startsWith("---")) {
|
|
82
|
+
description = cleaned;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch {}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const size = getDirSize(skillPath);
|
|
91
|
+
|
|
92
|
+
skills.push({
|
|
93
|
+
name,
|
|
94
|
+
path: skillPath,
|
|
95
|
+
description,
|
|
96
|
+
size,
|
|
97
|
+
installedAt: new Date(stat.birthtime).toISOString(),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return skills.sort((a, b) => a.name.localeCompare(b.name));
|
|
102
|
+
}
|
package/src/tui/bar.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { dim, green } from "./colors";
|
|
2
|
+
|
|
3
|
+
export function barChart(
|
|
4
|
+
items: { name: string; value: number }[],
|
|
5
|
+
width = 20,
|
|
6
|
+
): string {
|
|
7
|
+
if (items.length === 0) return "";
|
|
8
|
+
const max = Math.max(...items.map((i) => i.value));
|
|
9
|
+
const maxNameLen = Math.max(...items.map((i) => i.name.length));
|
|
10
|
+
|
|
11
|
+
return items
|
|
12
|
+
.map((item) => {
|
|
13
|
+
const filled = max === 0 ? 0 : Math.round((item.value / max) * width);
|
|
14
|
+
const empty = width - filled;
|
|
15
|
+
const bar = green("█".repeat(filled)) + dim("░".repeat(empty));
|
|
16
|
+
const name = item.name.padStart(maxNameLen);
|
|
17
|
+
return ` ${name} ${bar} ${item.value}`;
|
|
18
|
+
})
|
|
19
|
+
.join("\n");
|
|
20
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const isTTY = process.stdout.isTTY ?? false;
|
|
2
|
+
|
|
3
|
+
function ansi(code: string, s: string): string {
|
|
4
|
+
if (!isTTY) return s;
|
|
5
|
+
return `${code}${s}\x1b[0m`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const green = (s: string) => ansi("\x1b[32m", s);
|
|
9
|
+
export const red = (s: string) => ansi("\x1b[31m", s);
|
|
10
|
+
export const yellow = (s: string) => ansi("\x1b[33m", s);
|
|
11
|
+
export const dim = (s: string) => ansi("\x1b[2m", s);
|
|
12
|
+
export const bold = (s: string) => ansi("\x1b[1m", s);
|
|
13
|
+
export const cyan = (s: string) => ansi("\x1b[36m", s);
|
|
14
|
+
export const emerald = (s: string) => ansi("\x1b[38;5;48m", s);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { green, red, yellow } from "./colors";
|
|
2
|
+
|
|
3
|
+
export function healthGauge(score: number): string {
|
|
4
|
+
const clamped = Math.max(0, Math.min(100, Math.round(score)));
|
|
5
|
+
const filled = Math.round(clamped / 10);
|
|
6
|
+
const empty = 10 - filled;
|
|
7
|
+
const bar = "█".repeat(filled) + "░".repeat(empty);
|
|
8
|
+
const pct = `${clamped}%`;
|
|
9
|
+
const colored =
|
|
10
|
+
clamped >= 80 ? green(bar) : clamped >= 60 ? yellow(bar) : red(bar);
|
|
11
|
+
return `[${colored}] ${pct}`;
|
|
12
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { green } from "./colors";
|
|
2
|
+
|
|
3
|
+
const SPARKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
4
|
+
|
|
5
|
+
export function sparkline(values: number[]): string {
|
|
6
|
+
if (values.length === 0) return "";
|
|
7
|
+
const max = Math.max(...values);
|
|
8
|
+
if (max === 0) {
|
|
9
|
+
const line = (SPARKS[0] ?? "▁").repeat(values.length);
|
|
10
|
+
return green(line);
|
|
11
|
+
}
|
|
12
|
+
const chars = values.map((v) => {
|
|
13
|
+
const idx = Math.min(Math.floor((v / max) * 7), 7);
|
|
14
|
+
return SPARKS[idx] ?? "█";
|
|
15
|
+
});
|
|
16
|
+
return green(chars.join(""));
|
|
17
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface InstalledSkill {
|
|
2
|
+
name: string;
|
|
3
|
+
path: string;
|
|
4
|
+
description: string;
|
|
5
|
+
size: number;
|
|
6
|
+
installedAt: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SkillInvocation {
|
|
10
|
+
skillName: string;
|
|
11
|
+
timestamp: string;
|
|
12
|
+
sessionId: string;
|
|
13
|
+
project: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SkillStats {
|
|
17
|
+
name: string;
|
|
18
|
+
count: number;
|
|
19
|
+
dailyCounts: number[];
|
|
20
|
+
}
|