skill-analyzer 0.1.0
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 +40 -0
- package/dist/budget.d.ts +1 -0
- package/dist/budget.js +45 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +39 -0
- package/dist/dead.d.ts +1 -0
- package/dist/dead.js +47 -0
- package/dist/formatter.d.ts +8 -0
- package/dist/formatter.js +57 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/normalizer.d.ts +1 -0
- package/dist/normalizer.js +11 -0
- package/dist/registry.d.ts +8 -0
- package/dist/registry.js +51 -0
- package/dist/report.d.ts +1 -0
- package/dist/report.js +41 -0
- package/dist/scanner.d.ts +13 -0
- package/dist/scanner.js +114 -0
- package/dist/usage.d.ts +1 -0
- package/dist/usage.js +36 -0
- package/package.json +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mathbullet
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# skill-analyzer
|
|
2
|
+
|
|
3
|
+
Analyze your Claude Code skill library: usage frequency, dead skills, and description budget consumption.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx skill-analyzer report
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Install as Claude Code Plugin
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
/plugin add mathbullet/skill-analyzer
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Then use `/skill-analyzer` in Claude Code.
|
|
18
|
+
|
|
19
|
+
## Commands
|
|
20
|
+
|
|
21
|
+
| Command | Description |
|
|
22
|
+
|---------|-------------|
|
|
23
|
+
| `report` | Full analysis report (default) |
|
|
24
|
+
| `usage` | Skill usage frequency |
|
|
25
|
+
| `dead` | Unused skills |
|
|
26
|
+
| `budget` | Description budget consumption |
|
|
27
|
+
|
|
28
|
+
## Options
|
|
29
|
+
|
|
30
|
+
- `--days N` — Limit to last N days
|
|
31
|
+
- `--json` — JSON output
|
|
32
|
+
- `--skills-dir <path>` — Skills directory (default: `~/.claude/skills`)
|
|
33
|
+
- `--logs-dir <path>` — Logs directory (default: `~/.claude/projects`)
|
|
34
|
+
|
|
35
|
+
## What it does
|
|
36
|
+
|
|
37
|
+
- Scans `~/.claude/projects/**/*.jsonl` session logs for `Skill` tool invocations
|
|
38
|
+
- Parses `~/.claude/skills/*/SKILL.md` frontmatter for description budget calculation
|
|
39
|
+
- Normalizes skill names (e.g., `superpowers:brainstorming` → `brainstorming`)
|
|
40
|
+
- Reports unused skills and their budget cost for cleanup decisions
|
package/dist/budget.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function budgetCommand(args: string[]): void;
|
package/dist/budget.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { getSkillsDir, loadSkillRegistry } from "./registry.js";
|
|
3
|
+
import { sectionHeader, keyValue, budgetBar, makeTable } from "./formatter.js";
|
|
4
|
+
const OVERHEAD_PER_SKILL = 109;
|
|
5
|
+
const DEFAULT_BUDGET = 16_000;
|
|
6
|
+
export function budgetCommand(args) {
|
|
7
|
+
const skillsDir = getSkillsDir(args);
|
|
8
|
+
const skills = loadSkillRegistry(skillsDir);
|
|
9
|
+
const budget = parseInt(process.env["SLASH_COMMAND_TOOL_CHAR_BUDGET"] ?? String(DEFAULT_BUDGET), 10);
|
|
10
|
+
const isJson = args.includes("--json");
|
|
11
|
+
const entries = skills
|
|
12
|
+
.map((s) => ({
|
|
13
|
+
name: s.name,
|
|
14
|
+
descriptionLength: s.descriptionLength,
|
|
15
|
+
cost: s.descriptionLength + OVERHEAD_PER_SKILL,
|
|
16
|
+
}))
|
|
17
|
+
.sort((a, b) => b.cost - a.cost);
|
|
18
|
+
const totalCost = entries.reduce((sum, e) => sum + e.cost, 0);
|
|
19
|
+
if (isJson) {
|
|
20
|
+
console.log(JSON.stringify({ budget, totalCost, entries }, null, 2));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const overBudget = totalCost > budget;
|
|
24
|
+
sectionHeader("Description Budget", overBudget ? "OVER BUDGET" : undefined);
|
|
25
|
+
keyValue([
|
|
26
|
+
["Budget:", budgetBar(totalCost, budget)],
|
|
27
|
+
[
|
|
28
|
+
"Used:",
|
|
29
|
+
`${totalCost.toLocaleString()} / ${budget.toLocaleString()} chars`,
|
|
30
|
+
],
|
|
31
|
+
[
|
|
32
|
+
"Remaining:",
|
|
33
|
+
overBudget
|
|
34
|
+
? chalk.red(`-${(totalCost - budget).toLocaleString()} chars`)
|
|
35
|
+
: chalk.green(`${(budget - totalCost).toLocaleString()} chars`),
|
|
36
|
+
],
|
|
37
|
+
["Skills:", `${entries.length} (${OVERHEAD_PER_SKILL} chars overhead each)`],
|
|
38
|
+
]);
|
|
39
|
+
makeTable({
|
|
40
|
+
head: ["#", "Skill", "Description Chars", "Total Chars"],
|
|
41
|
+
colAligns: ["right", "left", "right", "right"],
|
|
42
|
+
rows: entries.map((e, i) => [i + 1, e.name, e.descriptionLength, e.cost]),
|
|
43
|
+
});
|
|
44
|
+
console.log();
|
|
45
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function run(args: string[]): void;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { budgetCommand } from "./budget.js";
|
|
2
|
+
import { usageCommand } from "./usage.js";
|
|
3
|
+
import { deadCommand } from "./dead.js";
|
|
4
|
+
import { reportCommand } from "./report.js";
|
|
5
|
+
export function run(args) {
|
|
6
|
+
const command = args[0] ?? "report";
|
|
7
|
+
switch (command) {
|
|
8
|
+
case "budget":
|
|
9
|
+
budgetCommand(args.slice(1));
|
|
10
|
+
break;
|
|
11
|
+
case "usage":
|
|
12
|
+
usageCommand(args.slice(1));
|
|
13
|
+
break;
|
|
14
|
+
case "dead":
|
|
15
|
+
deadCommand(args.slice(1));
|
|
16
|
+
break;
|
|
17
|
+
case "report":
|
|
18
|
+
reportCommand(args.slice(1));
|
|
19
|
+
break;
|
|
20
|
+
default:
|
|
21
|
+
printHelp();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function printHelp() {
|
|
25
|
+
console.log(`skill-analyzer - Analyze your Claude Code skill library
|
|
26
|
+
|
|
27
|
+
Usage: skill-analyzer <command> [options]
|
|
28
|
+
|
|
29
|
+
Commands:
|
|
30
|
+
report Full analysis report (default)
|
|
31
|
+
usage Skill usage frequency
|
|
32
|
+
dead Unused skills
|
|
33
|
+
budget Description budget consumption
|
|
34
|
+
|
|
35
|
+
Options:
|
|
36
|
+
--skills-dir <path> Skills directory (default: ~/.claude/skills)
|
|
37
|
+
--json Output as JSON
|
|
38
|
+
--help Show this help`);
|
|
39
|
+
}
|
package/dist/dead.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function deadCommand(args: string[]): void;
|
package/dist/dead.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { getSkillsDir, loadSkillRegistry } from "./registry.js";
|
|
3
|
+
import { getLogsDir, scanSessions, aggregateUsage } from "./scanner.js";
|
|
4
|
+
import { sectionHeader, keyValue, makeTable } from "./formatter.js";
|
|
5
|
+
export function deadCommand(args) {
|
|
6
|
+
const skillsDir = getSkillsDir(args);
|
|
7
|
+
const logsDir = getLogsDir(args);
|
|
8
|
+
const daysIdx = args.indexOf("--days");
|
|
9
|
+
const days = daysIdx !== -1 ? parseInt(args[daysIdx + 1], 10) : undefined;
|
|
10
|
+
const isJson = args.includes("--json");
|
|
11
|
+
const registry = loadSkillRegistry(skillsDir);
|
|
12
|
+
const invocations = scanSessions(logsDir, days);
|
|
13
|
+
const usage = aggregateUsage(invocations);
|
|
14
|
+
const usedSkills = new Set(usage.map((u) => u.skill));
|
|
15
|
+
const deadSkills = registry.filter((s) => !usedSkills.has(s.name));
|
|
16
|
+
if (isJson) {
|
|
17
|
+
console.log(JSON.stringify({ deadSkills: deadSkills.map((s) => s.name), count: deadSkills.length }, null, 2));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const period = days ? `last ${days} days` : "all time";
|
|
21
|
+
sectionHeader(`Dead Skills (${period})`);
|
|
22
|
+
const totalWaste = deadSkills.reduce((sum, s) => sum + s.descriptionLength + 109, 0);
|
|
23
|
+
keyValue([
|
|
24
|
+
["Dead:", `${deadSkills.length} of ${registry.length} local skills`],
|
|
25
|
+
[
|
|
26
|
+
"Savings:",
|
|
27
|
+
deadSkills.length > 0
|
|
28
|
+
? chalk.yellow(`${totalWaste.toLocaleString()} chars recoverable`)
|
|
29
|
+
: chalk.green("None needed"),
|
|
30
|
+
],
|
|
31
|
+
]);
|
|
32
|
+
if (deadSkills.length === 0) {
|
|
33
|
+
console.log(chalk.green(" All skills have been used at least once.\n"));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
makeTable({
|
|
37
|
+
head: ["#", "Skill", "Description Chars", "Total Chars"],
|
|
38
|
+
colAligns: ["right", "left", "right", "right"],
|
|
39
|
+
rows: deadSkills.map((s, i) => [
|
|
40
|
+
i + 1,
|
|
41
|
+
chalk.red(s.name),
|
|
42
|
+
s.descriptionLength,
|
|
43
|
+
s.descriptionLength + 109,
|
|
44
|
+
]),
|
|
45
|
+
});
|
|
46
|
+
console.log();
|
|
47
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function sectionHeader(title: string, warning?: string): void;
|
|
2
|
+
export declare function keyValue(pairs: [string, string][]): void;
|
|
3
|
+
export declare function budgetBar(used: number, total: number): string;
|
|
4
|
+
export declare function makeTable(options: {
|
|
5
|
+
head: string[];
|
|
6
|
+
rows: (string | number)[][];
|
|
7
|
+
colAligns?: ("left" | "right" | "middle")[];
|
|
8
|
+
}): void;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import Table from "cli-table3";
|
|
3
|
+
export function sectionHeader(title, warning) {
|
|
4
|
+
const header = warning
|
|
5
|
+
? chalk.red.bold(` ${title} ${warning}`)
|
|
6
|
+
: chalk.cyan.bold(` ${title}`);
|
|
7
|
+
console.log(`\n${header}\n`);
|
|
8
|
+
}
|
|
9
|
+
export function keyValue(pairs) {
|
|
10
|
+
for (const [key, value] of pairs) {
|
|
11
|
+
console.log(` ${chalk.dim(key)} ${value}`);
|
|
12
|
+
}
|
|
13
|
+
console.log();
|
|
14
|
+
}
|
|
15
|
+
export function budgetBar(used, total) {
|
|
16
|
+
const pct = used / total;
|
|
17
|
+
const width = 30;
|
|
18
|
+
const filled = Math.round(pct * width);
|
|
19
|
+
const empty = width - filled;
|
|
20
|
+
const color = pct > 1 ? chalk.red : pct > 0.7 ? chalk.yellow : chalk.green;
|
|
21
|
+
const bar = color("█".repeat(filled)) + chalk.dim("░".repeat(empty));
|
|
22
|
+
const label = color(`${(pct * 100).toFixed(0)}%`);
|
|
23
|
+
return `${bar} ${label}`;
|
|
24
|
+
}
|
|
25
|
+
export function makeTable(options) {
|
|
26
|
+
const table = new Table({
|
|
27
|
+
head: options.head.map((h) => chalk.bold(h)),
|
|
28
|
+
colAligns: options.colAligns,
|
|
29
|
+
style: {
|
|
30
|
+
head: [],
|
|
31
|
+
border: [],
|
|
32
|
+
"padding-left": 1,
|
|
33
|
+
"padding-right": 1,
|
|
34
|
+
},
|
|
35
|
+
chars: {
|
|
36
|
+
top: "─",
|
|
37
|
+
"top-mid": "┬",
|
|
38
|
+
"top-left": "┌",
|
|
39
|
+
"top-right": "┐",
|
|
40
|
+
bottom: "─",
|
|
41
|
+
"bottom-mid": "┴",
|
|
42
|
+
"bottom-left": "└",
|
|
43
|
+
"bottom-right": "┘",
|
|
44
|
+
left: "│",
|
|
45
|
+
"left-mid": "├",
|
|
46
|
+
mid: "─",
|
|
47
|
+
"mid-mid": "┼",
|
|
48
|
+
right: "│",
|
|
49
|
+
"right-mid": "┤",
|
|
50
|
+
middle: "│",
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
for (const row of options.rows) {
|
|
54
|
+
table.push(row.map(String));
|
|
55
|
+
}
|
|
56
|
+
console.log(table.toString());
|
|
57
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function normalizeSkillName(input: Record<string, unknown>): string | null;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function normalizeSkillName(input) {
|
|
2
|
+
const raw = (input["skill"] ?? input["skillName"] ?? input["skill_name"]);
|
|
3
|
+
if (!raw || typeof raw !== "string")
|
|
4
|
+
return null;
|
|
5
|
+
// Strip plugin prefix: "superpowers:brainstorming" → "brainstorming"
|
|
6
|
+
const colonIdx = raw.indexOf(":");
|
|
7
|
+
if (colonIdx !== -1) {
|
|
8
|
+
return raw.slice(colonIdx + 1);
|
|
9
|
+
}
|
|
10
|
+
return raw;
|
|
11
|
+
}
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
export function getSkillsDir(args) {
|
|
5
|
+
const idx = args.indexOf("--skills-dir");
|
|
6
|
+
if (idx !== -1 && args[idx + 1]) {
|
|
7
|
+
return args[idx + 1];
|
|
8
|
+
}
|
|
9
|
+
return join(homedir(), ".claude", "skills");
|
|
10
|
+
}
|
|
11
|
+
export function loadSkillRegistry(skillsDir) {
|
|
12
|
+
const entries = [];
|
|
13
|
+
let dirs;
|
|
14
|
+
try {
|
|
15
|
+
dirs = readdirSync(skillsDir);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
console.error(`Cannot read skills directory: ${skillsDir}`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
for (const dir of dirs) {
|
|
22
|
+
const skillMdPath = join(skillsDir, dir, "SKILL.md");
|
|
23
|
+
try {
|
|
24
|
+
statSync(skillMdPath);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const content = readFileSync(skillMdPath, "utf-8");
|
|
30
|
+
const { name, description } = parseFrontmatter(content, dir);
|
|
31
|
+
entries.push({
|
|
32
|
+
name,
|
|
33
|
+
description,
|
|
34
|
+
descriptionLength: description.length,
|
|
35
|
+
path: skillMdPath,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return entries;
|
|
39
|
+
}
|
|
40
|
+
function parseFrontmatter(content, fallbackName) {
|
|
41
|
+
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
42
|
+
if (!match) {
|
|
43
|
+
return { name: fallbackName, description: "" };
|
|
44
|
+
}
|
|
45
|
+
const frontmatter = match[1];
|
|
46
|
+
const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
|
|
47
|
+
const name = nameMatch ? nameMatch[1].trim() : fallbackName;
|
|
48
|
+
const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
|
|
49
|
+
const description = descMatch ? descMatch[1].trim() : "";
|
|
50
|
+
return { name, description };
|
|
51
|
+
}
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function reportCommand(args: string[]): void;
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { getSkillsDir, loadSkillRegistry } from "./registry.js";
|
|
2
|
+
import { getLogsDir, scanSessions, aggregateUsage } from "./scanner.js";
|
|
3
|
+
import { budgetCommand } from "./budget.js";
|
|
4
|
+
import { usageCommand } from "./usage.js";
|
|
5
|
+
import { deadCommand } from "./dead.js";
|
|
6
|
+
export function reportCommand(args) {
|
|
7
|
+
const isJson = args.includes("--json");
|
|
8
|
+
if (isJson) {
|
|
9
|
+
reportJson(args);
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
// Just run all three in sequence
|
|
13
|
+
usageCommand(args);
|
|
14
|
+
deadCommand(args);
|
|
15
|
+
budgetCommand(args);
|
|
16
|
+
}
|
|
17
|
+
function reportJson(args) {
|
|
18
|
+
const skillsDir = getSkillsDir(args);
|
|
19
|
+
const logsDir = getLogsDir(args);
|
|
20
|
+
const daysIdx = args.indexOf("--days");
|
|
21
|
+
const days = daysIdx !== -1 ? parseInt(args[daysIdx + 1], 10) : undefined;
|
|
22
|
+
const registry = loadSkillRegistry(skillsDir);
|
|
23
|
+
const invocations = scanSessions(logsDir, days);
|
|
24
|
+
const usage = aggregateUsage(invocations);
|
|
25
|
+
const usedSkills = new Set(usage.map((u) => u.skill));
|
|
26
|
+
const deadSkills = registry.filter((s) => !usedSkills.has(s.name)).map((s) => s.name);
|
|
27
|
+
const budget = parseInt(process.env["SLASH_COMMAND_TOOL_CHAR_BUDGET"] ?? "16000", 10);
|
|
28
|
+
const budgetEntries = registry
|
|
29
|
+
.map((s) => ({
|
|
30
|
+
name: s.name,
|
|
31
|
+
descriptionLength: s.descriptionLength,
|
|
32
|
+
cost: s.descriptionLength + 109,
|
|
33
|
+
}))
|
|
34
|
+
.sort((a, b) => b.cost - a.cost);
|
|
35
|
+
const totalCost = budgetEntries.reduce((sum, e) => sum + e.cost, 0);
|
|
36
|
+
console.log(JSON.stringify({
|
|
37
|
+
usage: { totalInvocations: invocations.length, skills: usage },
|
|
38
|
+
dead: { count: deadSkills.length, skills: deadSkills },
|
|
39
|
+
budget: { limit: budget, totalCost, entries: budgetEntries },
|
|
40
|
+
}, null, 2));
|
|
41
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface SkillInvocation {
|
|
2
|
+
skill: string;
|
|
3
|
+
timestamp: string;
|
|
4
|
+
sessionId: string;
|
|
5
|
+
}
|
|
6
|
+
export interface UsageResult {
|
|
7
|
+
skill: string;
|
|
8
|
+
count: number;
|
|
9
|
+
lastUsed: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function getLogsDir(args: string[]): string;
|
|
12
|
+
export declare function scanSessions(logsDir: string, daysLimit?: number): SkillInvocation[];
|
|
13
|
+
export declare function aggregateUsage(invocations: SkillInvocation[]): UsageResult[];
|
package/dist/scanner.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { normalizeSkillName } from "./normalizer.js";
|
|
5
|
+
export function getLogsDir(args) {
|
|
6
|
+
const idx = args.indexOf("--logs-dir");
|
|
7
|
+
if (idx !== -1 && args[idx + 1]) {
|
|
8
|
+
return args[idx + 1];
|
|
9
|
+
}
|
|
10
|
+
return join(homedir(), ".claude", "projects");
|
|
11
|
+
}
|
|
12
|
+
export function scanSessions(logsDir, daysLimit) {
|
|
13
|
+
const invocations = [];
|
|
14
|
+
const cutoff = daysLimit
|
|
15
|
+
? new Date(Date.now() - daysLimit * 86_400_000)
|
|
16
|
+
: null;
|
|
17
|
+
let projectDirs;
|
|
18
|
+
try {
|
|
19
|
+
projectDirs = readdirSync(logsDir);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
console.error(`Cannot read logs directory: ${logsDir}`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
for (const projectDir of projectDirs) {
|
|
26
|
+
const projectPath = join(logsDir, projectDir);
|
|
27
|
+
try {
|
|
28
|
+
if (!statSync(projectPath).isDirectory())
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
let files;
|
|
35
|
+
try {
|
|
36
|
+
files = readdirSync(projectPath).filter((f) => f.endsWith(".jsonl"));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
for (const file of files) {
|
|
42
|
+
const filePath = join(projectPath, file);
|
|
43
|
+
// Quick mtime filter when --days is specified
|
|
44
|
+
if (cutoff) {
|
|
45
|
+
try {
|
|
46
|
+
const mtime = statSync(filePath).mtime;
|
|
47
|
+
if (mtime < cutoff)
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const content = readFileSync(filePath, "utf-8");
|
|
55
|
+
for (const line of content.split("\n")) {
|
|
56
|
+
if (!line.includes('"Skill"'))
|
|
57
|
+
continue;
|
|
58
|
+
try {
|
|
59
|
+
const obj = JSON.parse(line);
|
|
60
|
+
const msg = obj["message"];
|
|
61
|
+
if (!msg)
|
|
62
|
+
continue;
|
|
63
|
+
const contentArr = msg["content"];
|
|
64
|
+
if (!Array.isArray(contentArr))
|
|
65
|
+
continue;
|
|
66
|
+
for (const block of contentArr) {
|
|
67
|
+
if (typeof block === "object" &&
|
|
68
|
+
block !== null &&
|
|
69
|
+
block["type"] === "tool_use" &&
|
|
70
|
+
block["name"] === "Skill") {
|
|
71
|
+
const input = block["input"];
|
|
72
|
+
if (!input)
|
|
73
|
+
continue;
|
|
74
|
+
const name = normalizeSkillName(input);
|
|
75
|
+
if (!name)
|
|
76
|
+
continue;
|
|
77
|
+
invocations.push({
|
|
78
|
+
skill: name,
|
|
79
|
+
timestamp: obj["timestamp"] ?? "",
|
|
80
|
+
sessionId: obj["sessionId"] ?? "",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Skip malformed lines
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return invocations;
|
|
92
|
+
}
|
|
93
|
+
export function aggregateUsage(invocations) {
|
|
94
|
+
const map = new Map();
|
|
95
|
+
for (const inv of invocations) {
|
|
96
|
+
const existing = map.get(inv.skill);
|
|
97
|
+
if (!existing) {
|
|
98
|
+
map.set(inv.skill, { count: 1, lastUsed: inv.timestamp });
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
existing.count++;
|
|
102
|
+
if (inv.timestamp > existing.lastUsed) {
|
|
103
|
+
existing.lastUsed = inv.timestamp;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return Array.from(map.entries())
|
|
108
|
+
.map(([skill, data]) => ({
|
|
109
|
+
skill,
|
|
110
|
+
count: data.count,
|
|
111
|
+
lastUsed: data.lastUsed,
|
|
112
|
+
}))
|
|
113
|
+
.sort((a, b) => b.count - a.count);
|
|
114
|
+
}
|
package/dist/usage.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function usageCommand(args: string[]): void;
|
package/dist/usage.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { getLogsDir, scanSessions, aggregateUsage } from "./scanner.js";
|
|
3
|
+
import { sectionHeader, keyValue, makeTable } from "./formatter.js";
|
|
4
|
+
export function usageCommand(args) {
|
|
5
|
+
const logsDir = getLogsDir(args);
|
|
6
|
+
const daysIdx = args.indexOf("--days");
|
|
7
|
+
const days = daysIdx !== -1 ? parseInt(args[daysIdx + 1], 10) : undefined;
|
|
8
|
+
const isJson = args.includes("--json");
|
|
9
|
+
const invocations = scanSessions(logsDir, days);
|
|
10
|
+
const usage = aggregateUsage(invocations);
|
|
11
|
+
if (isJson) {
|
|
12
|
+
console.log(JSON.stringify({ totalInvocations: invocations.length, skills: usage }, null, 2));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const period = days ? `last ${days} days` : "all time";
|
|
16
|
+
sectionHeader(`Skill Usage (${period})`);
|
|
17
|
+
keyValue([
|
|
18
|
+
["Invocations:", String(invocations.length)],
|
|
19
|
+
["Unique skills:", String(usage.length)],
|
|
20
|
+
]);
|
|
21
|
+
if (usage.length === 0) {
|
|
22
|
+
console.log(chalk.dim(" No skill invocations found.\n"));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
makeTable({
|
|
26
|
+
head: ["#", "Skill", "Count", "Last Used"],
|
|
27
|
+
colAligns: ["right", "left", "right", "left"],
|
|
28
|
+
rows: usage.map((u, i) => [
|
|
29
|
+
i + 1,
|
|
30
|
+
u.skill,
|
|
31
|
+
u.count,
|
|
32
|
+
u.lastUsed ? u.lastUsed.slice(0, 10) : "unknown",
|
|
33
|
+
]),
|
|
34
|
+
});
|
|
35
|
+
console.log();
|
|
36
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "skill-analyzer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Analyze Claude Code skill library: usage frequency, dead skills, description budget consumption",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"skill-analyzer": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"start": "node dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"claude-code",
|
|
15
|
+
"skills",
|
|
16
|
+
"analyzer",
|
|
17
|
+
"cli"
|
|
18
|
+
],
|
|
19
|
+
"author": "mathbullet",
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "^22.0.0",
|
|
26
|
+
"typescript": "^5.7.0"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist"
|
|
33
|
+
],
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"chalk": "^5.6.2",
|
|
36
|
+
"cli-table3": "^0.6.5"
|
|
37
|
+
}
|
|
38
|
+
}
|