@crafter/skillkit 0.1.7 → 0.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crafter/skillkit",
3
- "version": "0.1.7",
3
+ "version": "0.2.1",
4
4
  "description": "Local-first analytics for AI agent skills. Track usage, measure context budget, and prune what you don't use.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/bin.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  #!/usr/bin/env bun
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
2
4
  import { bold, cyan, dim, yellow } from "./tui/colors";
3
5
 
4
- const VERSION = "0.1.7";
6
+ const pkg = JSON.parse(
7
+ readFileSync(join(dirname(import.meta.dir), "package.json"), "utf-8"),
8
+ );
9
+ const VERSION: string = pkg.version;
5
10
 
6
11
  function printHelp(): void {
7
12
  console.log(`
@@ -115,8 +115,10 @@ export async function runHealth(): Promise<void> {
115
115
  console.log(`\n ${bold("SKILLKIT HEALTH REPORT")}\n`);
116
116
 
117
117
  const agents = getDetectedAgents();
118
- console.log(check(`${skills.length} skills across ${agents.length} agents`));
119
- console.log(dim(` ${agents.join(", ")}`));
118
+ console.log(check(`${skills.length} skills installed`));
119
+ if (agents.length > 0) {
120
+ console.log(dim(` ${agents.join(" + ")}`));
121
+ }
120
122
 
121
123
  if (dbExists && hasDbData) {
122
124
  console.log(
@@ -20,11 +20,6 @@ export function runList(): void {
20
20
  return;
21
21
  }
22
22
 
23
- const agentCounts = new Map<string, number>();
24
- for (const s of skills) {
25
- agentCounts.set(s.agent, (agentCounts.get(s.agent) ?? 0) + 1);
26
- }
27
-
28
23
  const totalSize = skills.reduce((acc, s) => acc + s.size, 0);
29
24
 
30
25
  console.log(`\n ${bold(`INSTALLED SKILLS (${skills.length})`)}\n`);
@@ -45,10 +40,7 @@ export function runList(): void {
45
40
  console.log(` ${name}${desc}${size}`);
46
41
  }
47
42
 
48
- const agentSummary = [...agentCounts.entries()]
49
- .map(([a, c]) => `${a} (${c})`)
50
- .join(", ");
51
43
  console.log(
52
- `\n ${dim(`Total: ${skills.length} skills | ${formatSize(totalSize)} | ${agentSummary}`)}\n`,
44
+ `\n ${dim(`Total: ${skills.length} skills | ${formatSize(totalSize)}`)}\n`,
53
45
  );
54
46
  }
@@ -1,8 +1,8 @@
1
1
  import { existsSync, readdirSync, statSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { upsertInstalledSkill } from "../db/queries";
2
+ import { basename, join } from "node:path";
3
+ import { upsertInstalledSkill, deduplicateInvocations } from "../db/queries";
4
4
  import { getDb } from "../db/schema";
5
- import { scanAllSessions, countAllSessions } from "../scanner/index";
5
+ import { countAllSessions, scanAllSessions } from "../scanner/index";
6
6
  import { getDetectedAgents, scanInstalledSkills } from "../scanner/skills";
7
7
  import { bold, cyan, dim } from "../tui/colors";
8
8
 
@@ -26,10 +26,12 @@ export async function runScan(): Promise<void> {
26
26
 
27
27
  const agents = getDetectedAgents();
28
28
  if (agents.length === 0) {
29
- console.log(`\n ${dim("No agent skill directories found.")}\n`);
29
+ console.log(
30
+ `\n ${dim("No supported agents found (Claude Code, OpenCode).")}\n`,
31
+ );
30
32
  return;
31
33
  }
32
- console.log(`\n ${dim(`Scanning ${agents.length} agents: ${agents.join(", ")}`)}`);
34
+ console.log(`\n ${dim(`Scanning ${agents.join(" + ")}`)}`);
33
35
 
34
36
  const skills = scanInstalledSkills();
35
37
 
@@ -84,10 +86,32 @@ export async function runScan(): Promise<void> {
84
86
  } catch {}
85
87
  }
86
88
 
89
+ const knownSkills = new Set<string>();
90
+ for (const skill of skills) {
91
+ knownSkills.add(skill.name);
92
+ knownSkills.add(basename(skill.path));
93
+ }
94
+ if (existsSync(localSkillsDir)) {
95
+ try {
96
+ for (const e of readdirSync(localSkillsDir)) {
97
+ try {
98
+ if (statSync(join(localSkillsDir, e)).isDirectory()) {
99
+ knownSkills.add(e);
100
+ }
101
+ } catch {}
102
+ }
103
+ } catch {}
104
+ }
105
+
106
+ const removed = deduplicateInvocations(db);
107
+ if (removed > 0) {
108
+ console.log(` ${dim(`Cleaned ${removed} duplicate entries`)}`);
109
+ }
110
+
87
111
  console.log(dim(" Scanning sessions..."));
88
112
 
89
113
  const sessionCount = countAllSessions();
90
- const newInvocations = await scanAllSessions(db);
114
+ const newInvocations = await scanAllSessions(db, knownSkills);
91
115
  const totalRow = db
92
116
  .query<{ count: number }, []>(
93
117
  "SELECT COUNT(*) as count FROM skill_invocations",
package/src/db/queries.ts CHANGED
@@ -76,18 +76,53 @@ export function recordInvocation(
76
76
  skillName: string,
77
77
  sessionId?: string,
78
78
  project?: string,
79
+ timestamp?: string,
79
80
  ): void {
81
+ const ts = timestamp ?? new Date().toISOString();
80
82
  db.run(
81
83
  "INSERT INTO skill_invocations (skill_name, timestamp, session_id, project) VALUES (?, ?, ?, ?)",
82
- [skillName, new Date().toISOString(), sessionId ?? null, project ?? null],
84
+ [skillName, ts, sessionId ?? null, project ?? null],
83
85
  );
84
- const date = new Date().toISOString().slice(0, 10);
86
+ const date = ts.slice(0, 10);
85
87
  db.run(
86
88
  "INSERT INTO skill_daily_stats (date, skill_name, count) VALUES (?, ?, 1) ON CONFLICT(date, skill_name) DO UPDATE SET count = count + 1",
87
89
  [date, skillName],
88
90
  );
89
91
  }
90
92
 
93
+ export function deduplicateInvocations(db: Database): number {
94
+ const before = db
95
+ .query<{ count: number }, []>(
96
+ "SELECT COUNT(*) as count FROM skill_invocations",
97
+ )
98
+ .get()?.count ?? 0;
99
+
100
+ db.run(`
101
+ DELETE FROM skill_invocations WHERE id NOT IN (
102
+ SELECT MIN(id) FROM skill_invocations
103
+ GROUP BY skill_name, session_id, timestamp
104
+ )
105
+ `);
106
+
107
+ const after = db
108
+ .query<{ count: number }, []>(
109
+ "SELECT COUNT(*) as count FROM skill_invocations",
110
+ )
111
+ .get()?.count ?? 0;
112
+
113
+ if (before !== after) {
114
+ db.run("DELETE FROM skill_daily_stats");
115
+ db.run(`
116
+ INSERT INTO skill_daily_stats (date, skill_name, count)
117
+ SELECT date(timestamp), skill_name, COUNT(*)
118
+ FROM skill_invocations
119
+ GROUP BY date(timestamp), skill_name
120
+ `);
121
+ }
122
+
123
+ return before - after;
124
+ }
125
+
91
126
  export function upsertInstalledSkill(
92
127
  db: Database,
93
128
  name: string,
package/src/index.ts CHANGED
@@ -12,8 +12,8 @@ export {
12
12
  upsertInstalledSkill,
13
13
  } from "./db/queries";
14
14
  export { getDb } from "./db/schema";
15
- export { scanAllSessions, countAllSessions } from "./scanner/index";
16
15
  export { parseSessionFile } from "./scanner/connectors/claude";
16
+ export { countAllSessions, scanAllSessions } from "./scanner/index";
17
17
  export { getDetectedAgents, scanInstalledSkills } from "./scanner/skills";
18
18
  export type {
19
19
  InstalledSkill,
@@ -19,7 +19,28 @@ function extractSkillName(block: ToolUseBlock): string | null {
19
19
  return null;
20
20
  }
21
21
 
22
- export function parseSessionFile(filePath: string): Invocation[] {
22
+ const COMMAND_NAME_RE = /<command-name>\/?([a-zA-Z][\w-]*(?::[\w-]*)*)<\/command-name>/g;
23
+
24
+ function extractCommandNames(
25
+ text: string,
26
+ knownSkills: Set<string>,
27
+ ): string[] {
28
+ const names: string[] = [];
29
+ let match: RegExpExecArray | null;
30
+ while ((match = COMMAND_NAME_RE.exec(text)) !== null) {
31
+ const name = match[1];
32
+ if (knownSkills.has(name)) {
33
+ names.push(name);
34
+ }
35
+ }
36
+ COMMAND_NAME_RE.lastIndex = 0;
37
+ return names;
38
+ }
39
+
40
+ export function parseSessionFile(
41
+ filePath: string,
42
+ knownSkills: Set<string> = new Set(),
43
+ ): Invocation[] {
23
44
  const results: Invocation[] = [];
24
45
  const sessionId = basename(filePath, ".jsonl");
25
46
 
@@ -49,10 +70,35 @@ export function parseSessionFile(filePath: string): Invocation[] {
49
70
  : new Date().toISOString();
50
71
 
51
72
  const msg = obj.message as
52
- | { content: Array<Record<string, unknown>> }
73
+ | { content: Array<Record<string, unknown>> | string }
53
74
  | undefined;
75
+
76
+ if (obj.type === "user" && msg) {
77
+ const text =
78
+ typeof msg.content === "string"
79
+ ? msg.content
80
+ : Array.isArray(msg.content)
81
+ ? msg.content
82
+ .filter(
83
+ (b): b is { type: string; text: string } =>
84
+ typeof b === "object" &&
85
+ b !== null &&
86
+ b.type === "text" &&
87
+ typeof b.text === "string",
88
+ )
89
+ .map((b) => b.text)
90
+ .join("\n")
91
+ : "";
92
+ for (const name of extractCommandNames(text, knownSkills)) {
93
+ results.push({ skillName: name, timestamp, sessionId });
94
+ }
95
+ continue;
96
+ }
97
+
54
98
  const msgContent =
55
- obj.type === "assistant" && msg ? msg.content : null;
99
+ obj.type === "assistant" && msg && Array.isArray(msg.content)
100
+ ? (msg.content as Array<Record<string, unknown>>)
101
+ : null;
56
102
 
57
103
  if (!Array.isArray(msgContent)) continue;
58
104
 
@@ -63,9 +109,7 @@ export function parseSessionFile(filePath: string): Invocation[] {
63
109
  block.type === "tool_use" &&
64
110
  (block as unknown as ToolUseBlock).name === "Skill"
65
111
  ) {
66
- const skillName = extractSkillName(
67
- block as unknown as ToolUseBlock,
68
- );
112
+ const skillName = extractSkillName(block as unknown as ToolUseBlock);
69
113
  if (skillName) {
70
114
  results.push({ skillName, timestamp, sessionId });
71
115
  }
@@ -91,6 +135,7 @@ export function countClaudeSessions(): number {
91
135
  export async function scanClaudeSessions(
92
136
  db: Database,
93
137
  trackedSet: Set<string>,
138
+ knownSkills: Set<string> = new Set(),
94
139
  ): Promise<number> {
95
140
  const projectsDir = join(homedir(), ".claude", "projects");
96
141
  if (!existsSync(projectsDir)) return 0;
@@ -104,7 +149,7 @@ export async function scanClaudeSessions(
104
149
 
105
150
  let total = 0;
106
151
  for (const file of files) {
107
- const invocations = parseSessionFile(file);
152
+ const invocations = parseSessionFile(file, knownSkills);
108
153
  total += recordNewInvocations(db, trackedSet, invocations);
109
154
  }
110
155
 
@@ -1,10 +1,10 @@
1
- import { Database as BunDatabase } from "bun:sqlite";
2
1
  import type { Database } from "bun:sqlite";
2
+ import { Database as BunDatabase } from "bun:sqlite";
3
3
  import { existsSync } from "node:fs";
4
4
  import { homedir, platform } from "node:os";
5
5
  import { join } from "node:path";
6
- import { recordNewInvocations } from "../index";
7
6
  import type { Invocation } from "../index";
7
+ import { recordNewInvocations } from "../index";
8
8
 
9
9
  function getDbPath(): string | null {
10
10
  const os = platform();
@@ -48,9 +48,7 @@ export function countOpenCodeSessions(): number {
48
48
 
49
49
  try {
50
50
  const row = ocDb
51
- .query<{ count: number }, []>(
52
- "SELECT COUNT(*) as count FROM session",
53
- )
51
+ .query<{ count: number }, []>("SELECT COUNT(*) as count FROM session")
54
52
  .get();
55
53
  return row?.count ?? 0;
56
54
  } catch {
@@ -70,7 +68,7 @@ export function scanOpenCodeSessions(
70
68
  try {
71
69
  const rows = ocDb
72
70
  .query<PartRow, []>(
73
- "SELECT p.session_id, p.time_created, p.data FROM part p WHERE p.data LIKE '%\"tool\":\"skill\"%'",
71
+ 'SELECT p.session_id, p.time_created, p.data FROM part p WHERE p.data LIKE \'%"tool":"skill"%\'',
74
72
  )
75
73
  .all();
76
74
 
@@ -1,9 +1,9 @@
1
1
  import type { Database } from "bun:sqlite";
2
2
  import { recordInvocation } from "../db/queries";
3
- import { scanClaudeSessions, countClaudeSessions } from "./connectors/claude";
3
+ import { countClaudeSessions, scanClaudeSessions } from "./connectors/claude";
4
4
  import {
5
- scanOpenCodeSessions,
6
5
  countOpenCodeSessions,
6
+ scanOpenCodeSessions,
7
7
  } from "./connectors/opencode";
8
8
 
9
9
  interface AlreadyTracked {
@@ -35,7 +35,7 @@ export function recordNewInvocations(
35
35
  for (const inv of invocations) {
36
36
  const key = `${inv.sessionId}::${inv.timestamp}`;
37
37
  if (!trackedSet.has(key)) {
38
- recordInvocation(db, inv.skillName, inv.sessionId);
38
+ recordInvocation(db, inv.skillName, inv.sessionId, undefined, inv.timestamp);
39
39
  trackedSet.add(key);
40
40
  count++;
41
41
  }
@@ -43,10 +43,13 @@ export function recordNewInvocations(
43
43
  return count;
44
44
  }
45
45
 
46
- export async function scanAllSessions(db: Database): Promise<number> {
46
+ export async function scanAllSessions(
47
+ db: Database,
48
+ knownSkills: Set<string> = new Set(),
49
+ ): Promise<number> {
47
50
  const trackedSet = getTrackedSet(db);
48
51
  let total = 0;
49
- total += await scanClaudeSessions(db, trackedSet);
52
+ total += await scanClaudeSessions(db, trackedSet, knownSkills);
50
53
  total += scanOpenCodeSessions(db, trackedSet);
51
54
  return total;
52
55
  }
@@ -3,22 +3,23 @@ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import type { InstalledSkill } from "../types";
5
5
 
6
- const AGENT_SKILL_PATHS: Array<{ agent: string; dir: string }> = [
6
+ const SUPPORTED_AGENTS: Array<{ agent: string; dir: string }> = [
7
7
  { agent: "Claude Code", dir: join(homedir(), ".claude", "skills") },
8
- { agent: "Cursor", dir: join(homedir(), ".cursor", "skills") },
9
- { agent: "Codex", dir: join(homedir(), ".codex", "skills") },
10
- { agent: "Windsurf", dir: join(homedir(), ".codeium", "windsurf", "skills") },
11
- { agent: "Gemini CLI", dir: join(homedir(), ".gemini", "skills") },
12
- { agent: "Cline", dir: join(homedir(), ".cline", "skills") },
13
- { agent: "Roo Code", dir: join(homedir(), ".roo", "skills") },
14
- { agent: "Continue", dir: join(homedir(), ".continue", "skills") },
15
8
  { agent: "OpenCode", dir: join(homedir(), ".config", "opencode", "skills") },
16
- { agent: "GitHub Copilot", dir: join(homedir(), ".copilot", "skills") },
17
- { agent: "OpenHands", dir: join(homedir(), ".openhands", "skills") },
18
- { agent: "Amp", dir: join(homedir(), ".config", "agents", "skills") },
19
- { agent: "Goose", dir: join(homedir(), ".config", "goose", "skills") },
20
- { agent: "Kilo Code", dir: join(homedir(), ".kilocode", "skills") },
21
- { agent: "Trae", dir: join(homedir(), ".trae", "skills") },
9
+ // Planned needs session connector to enable full analytics pipeline
10
+ // { agent: "Cursor", dir: join(homedir(), ".cursor", "skills") }, // GH-1: injects skills as context rules, no discrete tool_use
11
+ // { agent: "Codex", dir: join(homedir(), ".codex", "skills") }, // GH-2
12
+ // { agent: "Windsurf", dir: join(homedir(), ".codeium", "windsurf", "skills") }, // GH-3
13
+ // { agent: "Gemini CLI", dir: join(homedir(), ".gemini", "skills") }, // GH-4
14
+ // { agent: "Cline", dir: join(homedir(), ".cline", "skills") }, // GH-5
15
+ // { agent: "Roo Code", dir: join(homedir(), ".roo", "skills") }, // GH-6
16
+ // { agent: "Continue", dir: join(homedir(), ".continue", "skills") }, // GH-7
17
+ // { agent: "GitHub Copilot", dir: join(homedir(), ".copilot", "skills") }, // GH-8
18
+ // { agent: "OpenHands", dir: join(homedir(), ".openhands", "skills") }, // GH-9
19
+ // { agent: "Amp", dir: join(homedir(), ".config", "agents", "skills") }, // GH-10
20
+ // { agent: "Goose", dir: join(homedir(), ".config", "goose", "skills") }, // GH-11
21
+ // { agent: "Kilo Code", dir: join(homedir(), ".kilocode", "skills") }, // GH-12
22
+ // { agent: "Trae", dir: join(homedir(), ".trae", "skills") }, // GH-13
22
23
  ];
23
24
 
24
25
  function parseYamlFrontmatter(content: string): Record<string, string> {
@@ -56,10 +57,7 @@ function getDirSize(dirPath: string): number {
56
57
  return total;
57
58
  }
58
59
 
59
- function scanSkillsDir(
60
- skillsDir: string,
61
- agent: string,
62
- ): InstalledSkill[] {
60
+ function scanSkillsDir(skillsDir: string, agent: string): InstalledSkill[] {
63
61
  if (!existsSync(skillsDir)) return [];
64
62
 
65
63
  const skills: InstalledSkill[] = [];
@@ -125,7 +123,7 @@ export function scanInstalledSkills(): InstalledSkill[] {
125
123
  const allSkills: InstalledSkill[] = [];
126
124
  const seen = new Set<string>();
127
125
 
128
- for (const { agent, dir } of AGENT_SKILL_PATHS) {
126
+ for (const { agent, dir } of SUPPORTED_AGENTS) {
129
127
  const skills = scanSkillsDir(dir, agent);
130
128
  for (const skill of skills) {
131
129
  try {
@@ -143,7 +141,7 @@ export function scanInstalledSkills(): InstalledSkill[] {
143
141
 
144
142
  export function getDetectedAgents(): string[] {
145
143
  const agents: string[] = [];
146
- for (const { agent, dir } of AGENT_SKILL_PATHS) {
144
+ for (const { agent, dir } of SUPPORTED_AGENTS) {
147
145
  if (existsSync(dir)) agents.push(agent);
148
146
  }
149
147
  return agents;