skillstat 0.1.0__py3-none-any.whl
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.
- skillstat/__init__.py +3 -0
- skillstat/__main__.py +4 -0
- skillstat/cli.py +1254 -0
- skillstat-0.1.0.dist-info/METADATA +111 -0
- skillstat-0.1.0.dist-info/RECORD +8 -0
- skillstat-0.1.0.dist-info/WHEEL +4 -0
- skillstat-0.1.0.dist-info/entry_points.txt +2 -0
- skillstat-0.1.0.dist-info/licenses/LICENSE +21 -0
skillstat/cli.py
ADDED
|
@@ -0,0 +1,1254 @@
|
|
|
1
|
+
"""
|
|
2
|
+
skillstat — Diagnose, audit, and optimize your AI agent skill usage.
|
|
3
|
+
|
|
4
|
+
Supports (auto-detected):
|
|
5
|
+
- GitHub Copilot CLI (~/.copilot/session-store.db)
|
|
6
|
+
- Claude Code (~/.claude/history.jsonl + session JSONL)
|
|
7
|
+
- Codex CLI (~/.codex/sessions/**/*.jsonl)
|
|
8
|
+
- OpenCode (~/.local/share/opencode/opencode.db)
|
|
9
|
+
- Grok CLI (~/.grok/sessions/)
|
|
10
|
+
- Droid CLI (~/.factory/sessions/)
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
python3 skill_usage_stats.py # interactive
|
|
14
|
+
python3 skill_usage_stats.py --source copilot --all # non-interactive
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
import sqlite3
|
|
23
|
+
from abc import ABC, abstractmethod
|
|
24
|
+
from collections import Counter, defaultdict
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from datetime import datetime, timedelta, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from InquirerPy import inquirer
|
|
30
|
+
from InquirerPy.utils import InquirerPyStyle
|
|
31
|
+
from rich.console import Console
|
|
32
|
+
from rich.panel import Panel
|
|
33
|
+
from rich.table import Table
|
|
34
|
+
from rich.text import Text
|
|
35
|
+
from rich import box
|
|
36
|
+
|
|
37
|
+
HOME = Path.home()
|
|
38
|
+
console = Console()
|
|
39
|
+
|
|
40
|
+
PROMPT_STYLE = InquirerPyStyle(
|
|
41
|
+
{
|
|
42
|
+
"questionmark": "#e5c07b bold",
|
|
43
|
+
"question": "",
|
|
44
|
+
"pointer": "#61afef bold",
|
|
45
|
+
"highlighted": "#61afef bold",
|
|
46
|
+
"answer": "#98c379 bold",
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── Data Model ──────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class SkillCall:
|
|
56
|
+
skill: str
|
|
57
|
+
timestamp: str # ISO-8601
|
|
58
|
+
project: str
|
|
59
|
+
session_id: str
|
|
60
|
+
source: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── Provider Interface ──────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Provider(ABC):
|
|
67
|
+
@property
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def name(self) -> str: ...
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
def available(self) -> bool: ...
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def collect(self) -> list[SkillCall]: ...
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ── GitHub Copilot ──────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
COPILOT_DB = HOME / ".copilot" / "session-store.db"
|
|
81
|
+
SKILL_CONTEXT_RE = re.compile(r'<skill-context\s+name="([^"]+)"')
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class CopilotProvider(Provider):
|
|
85
|
+
name = "GitHub Copilot"
|
|
86
|
+
|
|
87
|
+
def available(self) -> bool:
|
|
88
|
+
return COPILOT_DB.exists()
|
|
89
|
+
|
|
90
|
+
def collect(self) -> list[SkillCall]:
|
|
91
|
+
if not self.available():
|
|
92
|
+
return []
|
|
93
|
+
conn = sqlite3.connect(COPILOT_DB)
|
|
94
|
+
conn.row_factory = sqlite3.Row
|
|
95
|
+
rows = conn.execute(
|
|
96
|
+
"SELECT s.id AS sid, s.cwd, t.timestamp AS ts, t.user_message "
|
|
97
|
+
"FROM sessions s JOIN turns t ON t.session_id = s.id "
|
|
98
|
+
"WHERE t.user_message LIKE '%<skill-context name=\"%'"
|
|
99
|
+
).fetchall()
|
|
100
|
+
conn.close()
|
|
101
|
+
|
|
102
|
+
calls: list[SkillCall] = []
|
|
103
|
+
for row in rows:
|
|
104
|
+
for m in SKILL_CONTEXT_RE.finditer(row["user_message"] or ""):
|
|
105
|
+
calls.append(SkillCall(
|
|
106
|
+
skill=m.group(1),
|
|
107
|
+
timestamp=row["ts"] or "",
|
|
108
|
+
project=row["cwd"] or "",
|
|
109
|
+
session_id=row["sid"],
|
|
110
|
+
source=self.name,
|
|
111
|
+
))
|
|
112
|
+
return calls
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ── Claude Code ─────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
CLAUDE_HOME = HOME / ".claude"
|
|
118
|
+
CLAUDE_HISTORY = CLAUDE_HOME / "history.jsonl"
|
|
119
|
+
CLAUDE_PROJECTS = CLAUDE_HOME / "projects"
|
|
120
|
+
|
|
121
|
+
CLAUDE_BUILTINS = {
|
|
122
|
+
"clear", "model", "usage", "resume", "new", "quit", "exit", "login",
|
|
123
|
+
"logout", "help", "config", "compact", "doctor", "cost", "effort",
|
|
124
|
+
"memory", "status", "skills", "permissions", "mcp", "terminal-setup",
|
|
125
|
+
"remote-env", "remote-control", "fast", "plan", "plugin", "rename",
|
|
126
|
+
"init", "review", "reload-plugins",
|
|
127
|
+
}
|
|
128
|
+
CLAUDE_SLASH_RE = re.compile(r"^/([a-zA-Z][a-zA-Z0-9_-]*)(?:\s|$)")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ClaudeCodeProvider(Provider):
|
|
132
|
+
name = "Claude Code"
|
|
133
|
+
|
|
134
|
+
def available(self) -> bool:
|
|
135
|
+
return CLAUDE_HISTORY.exists()
|
|
136
|
+
|
|
137
|
+
def collect(self) -> list[SkillCall]:
|
|
138
|
+
if not self.available():
|
|
139
|
+
return []
|
|
140
|
+
|
|
141
|
+
calls: list[SkillCall] = []
|
|
142
|
+
seen: set[str] = set()
|
|
143
|
+
|
|
144
|
+
# Source 1: history.jsonl — /slash-command invocations
|
|
145
|
+
self._collect_history(calls, seen)
|
|
146
|
+
# Source 2: session JSONL — Skill tool_use calls
|
|
147
|
+
if CLAUDE_PROJECTS.exists():
|
|
148
|
+
self._collect_projects(calls, seen)
|
|
149
|
+
|
|
150
|
+
return calls
|
|
151
|
+
|
|
152
|
+
def _collect_history(self, calls: list[SkillCall], seen: set[str]) -> None:
|
|
153
|
+
try:
|
|
154
|
+
lines = CLAUDE_HISTORY.read_text().splitlines()
|
|
155
|
+
except OSError:
|
|
156
|
+
return
|
|
157
|
+
for line in lines:
|
|
158
|
+
if not line.strip():
|
|
159
|
+
continue
|
|
160
|
+
try:
|
|
161
|
+
entry = json.loads(line)
|
|
162
|
+
except json.JSONDecodeError:
|
|
163
|
+
continue
|
|
164
|
+
display = entry.get("display", "")
|
|
165
|
+
m = CLAUDE_SLASH_RE.match(display)
|
|
166
|
+
if not m:
|
|
167
|
+
continue
|
|
168
|
+
skill = m.group(1)
|
|
169
|
+
if skill in CLAUDE_BUILTINS:
|
|
170
|
+
continue
|
|
171
|
+
raw_ts = entry.get("timestamp", 0)
|
|
172
|
+
ts = _ms_to_iso(raw_ts) if isinstance(raw_ts, (int, float)) else ""
|
|
173
|
+
key = f"{skill}:{raw_ts}"
|
|
174
|
+
if key in seen:
|
|
175
|
+
continue
|
|
176
|
+
seen.add(key)
|
|
177
|
+
calls.append(SkillCall(
|
|
178
|
+
skill=skill, timestamp=ts,
|
|
179
|
+
project=entry.get("project", ""),
|
|
180
|
+
session_id=entry.get("sessionId", ""),
|
|
181
|
+
source=self.name,
|
|
182
|
+
))
|
|
183
|
+
|
|
184
|
+
def _collect_projects(self, calls: list[SkillCall], seen: set[str]) -> None:
|
|
185
|
+
for fpath in CLAUDE_PROJECTS.rglob("*.jsonl"):
|
|
186
|
+
self._parse_session_file(fpath, calls, seen)
|
|
187
|
+
|
|
188
|
+
def _parse_session_file(self, path: Path, calls: list[SkillCall], seen: set[str]) -> None:
|
|
189
|
+
try:
|
|
190
|
+
content = path.read_text()
|
|
191
|
+
except OSError:
|
|
192
|
+
return
|
|
193
|
+
session_id = path.stem
|
|
194
|
+
project = ""
|
|
195
|
+
for line in content.splitlines():
|
|
196
|
+
if not line.strip():
|
|
197
|
+
continue
|
|
198
|
+
# Fast cwd extraction
|
|
199
|
+
if not project and '"cwd"' in line:
|
|
200
|
+
try:
|
|
201
|
+
entry = json.loads(line)
|
|
202
|
+
if entry.get("cwd"):
|
|
203
|
+
project = entry["cwd"]
|
|
204
|
+
except (json.JSONDecodeError, KeyError):
|
|
205
|
+
pass
|
|
206
|
+
if '"Skill"' not in line:
|
|
207
|
+
continue
|
|
208
|
+
try:
|
|
209
|
+
entry = json.loads(line)
|
|
210
|
+
except json.JSONDecodeError:
|
|
211
|
+
continue
|
|
212
|
+
if not project and entry.get("cwd"):
|
|
213
|
+
project = entry["cwd"]
|
|
214
|
+
if entry.get("type") != "assistant":
|
|
215
|
+
continue
|
|
216
|
+
msg = entry.get("message", {})
|
|
217
|
+
for part in msg.get("content", []):
|
|
218
|
+
if not isinstance(part, dict):
|
|
219
|
+
continue
|
|
220
|
+
if part.get("type") != "tool_use" or part.get("name") != "Skill":
|
|
221
|
+
continue
|
|
222
|
+
skill = (part.get("input") or {}).get("skill", "")
|
|
223
|
+
if not skill or skill in CLAUDE_BUILTINS:
|
|
224
|
+
continue
|
|
225
|
+
raw_ts = entry.get("timestamp", "")
|
|
226
|
+
ts = raw_ts if isinstance(raw_ts, str) else _ms_to_iso(raw_ts)
|
|
227
|
+
key = f"{skill}:{ts}"
|
|
228
|
+
if key in seen:
|
|
229
|
+
continue
|
|
230
|
+
seen.add(key)
|
|
231
|
+
calls.append(SkillCall(
|
|
232
|
+
skill=skill, timestamp=ts,
|
|
233
|
+
project=project or entry.get("cwd", ""),
|
|
234
|
+
session_id=session_id, source=self.name,
|
|
235
|
+
))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ── Codex CLI ───────────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
CODEX_SESSIONS = HOME / ".codex" / "sessions"
|
|
241
|
+
CODEX_SKILL_NAME_RE = re.compile(r"<name>([^<]+)</name>")
|
|
242
|
+
CODEX_BUILTINS = {
|
|
243
|
+
"exit", "help", "model", "clear", "compact", "undo", "diff",
|
|
244
|
+
"history", "settings", "version", "approve", "status",
|
|
245
|
+
"imagegen", "openai-docs", "plugin-creator", "skill-creator", "skill-installer",
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class CodexProvider(Provider):
|
|
250
|
+
name = "Codex CLI"
|
|
251
|
+
|
|
252
|
+
def available(self) -> bool:
|
|
253
|
+
return CODEX_SESSIONS.exists()
|
|
254
|
+
|
|
255
|
+
def collect(self) -> list[SkillCall]:
|
|
256
|
+
if not self.available():
|
|
257
|
+
return []
|
|
258
|
+
calls: list[SkillCall] = []
|
|
259
|
+
for fpath in CODEX_SESSIONS.rglob("*.jsonl"):
|
|
260
|
+
self._parse_session(fpath, calls)
|
|
261
|
+
return calls
|
|
262
|
+
|
|
263
|
+
def _parse_session(self, path: Path, calls: list[SkillCall]) -> None:
|
|
264
|
+
try:
|
|
265
|
+
lines = path.read_text().splitlines()
|
|
266
|
+
except OSError:
|
|
267
|
+
return
|
|
268
|
+
project = ""
|
|
269
|
+
session_id = ""
|
|
270
|
+
for line in lines:
|
|
271
|
+
if not line.strip():
|
|
272
|
+
continue
|
|
273
|
+
try:
|
|
274
|
+
entry = json.loads(line)
|
|
275
|
+
except json.JSONDecodeError:
|
|
276
|
+
continue
|
|
277
|
+
if entry.get("type") == "session_meta" and not session_id:
|
|
278
|
+
project = (entry.get("payload") or {}).get("cwd", "")
|
|
279
|
+
session_id = (entry.get("payload") or {}).get("id", "")
|
|
280
|
+
if entry.get("type") == "response_item":
|
|
281
|
+
payload = entry.get("payload") or {}
|
|
282
|
+
for part in payload.get("content", []):
|
|
283
|
+
if not isinstance(part, dict) or part.get("type") != "input_text":
|
|
284
|
+
continue
|
|
285
|
+
text = part.get("text", "")
|
|
286
|
+
if "<skill>" not in text:
|
|
287
|
+
continue
|
|
288
|
+
for m in CODEX_SKILL_NAME_RE.finditer(text):
|
|
289
|
+
skill = m.group(1)
|
|
290
|
+
if skill in CODEX_BUILTINS:
|
|
291
|
+
continue
|
|
292
|
+
ts = entry.get("timestamp", "")
|
|
293
|
+
if isinstance(ts, str):
|
|
294
|
+
pass
|
|
295
|
+
else:
|
|
296
|
+
ts = _ms_to_iso(ts)
|
|
297
|
+
calls.append(SkillCall(
|
|
298
|
+
skill=skill, timestamp=ts, project=project,
|
|
299
|
+
session_id=session_id, source=self.name,
|
|
300
|
+
))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ── Grok CLI ────────────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
GROK_SESSIONS = HOME / ".grok" / "sessions"
|
|
306
|
+
GROK_COMMAND_RE = re.compile(r"<command-name>([^<]+)</command-name>")
|
|
307
|
+
GROK_BUILTINS = {
|
|
308
|
+
"compact", "always-approve", "context", "plugins", "reload-plugins",
|
|
309
|
+
"session-info", "imagine", "imagine-video", "feedback", "loop",
|
|
310
|
+
"help", "memory", "clear", "exit",
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class GrokProvider(Provider):
|
|
315
|
+
name = "Grok CLI"
|
|
316
|
+
|
|
317
|
+
def available(self) -> bool:
|
|
318
|
+
return GROK_SESSIONS.exists()
|
|
319
|
+
|
|
320
|
+
def collect(self) -> list[SkillCall]:
|
|
321
|
+
if not self.available():
|
|
322
|
+
return []
|
|
323
|
+
calls: list[SkillCall] = []
|
|
324
|
+
for proj_entry in sorted(GROK_SESSIONS.iterdir()):
|
|
325
|
+
if not proj_entry.name.startswith("%2F"):
|
|
326
|
+
continue
|
|
327
|
+
try:
|
|
328
|
+
project = proj_entry.name.replace("%2F", "/").replace("%20", " ")
|
|
329
|
+
except Exception:
|
|
330
|
+
project = proj_entry.name
|
|
331
|
+
if not proj_entry.is_dir():
|
|
332
|
+
continue
|
|
333
|
+
for session_dir in sorted(proj_entry.iterdir()):
|
|
334
|
+
if not session_dir.is_dir():
|
|
335
|
+
continue
|
|
336
|
+
seen: set[str] = set()
|
|
337
|
+
# Source 1: updates.jsonl
|
|
338
|
+
updates = session_dir / "updates.jsonl"
|
|
339
|
+
if updates.exists():
|
|
340
|
+
self._parse_updates(updates, project, session_dir.name, calls, seen)
|
|
341
|
+
# Source 2: chat_history.jsonl
|
|
342
|
+
chat = session_dir / "chat_history.jsonl"
|
|
343
|
+
if chat.exists():
|
|
344
|
+
self._parse_chat(chat, project, session_dir.name, calls, seen)
|
|
345
|
+
return calls
|
|
346
|
+
|
|
347
|
+
def _parse_updates(self, path: Path, project: str, session_id: str,
|
|
348
|
+
calls: list[SkillCall], seen: set[str]) -> None:
|
|
349
|
+
try:
|
|
350
|
+
lines = path.read_text().splitlines()
|
|
351
|
+
except OSError:
|
|
352
|
+
return
|
|
353
|
+
for line in lines:
|
|
354
|
+
if not line.strip():
|
|
355
|
+
continue
|
|
356
|
+
try:
|
|
357
|
+
record = json.loads(line)
|
|
358
|
+
except json.JSONDecodeError:
|
|
359
|
+
continue
|
|
360
|
+
params = record.get("params") or {}
|
|
361
|
+
update = params.get("update") or {}
|
|
362
|
+
if update.get("sessionUpdate") != "user_message_chunk":
|
|
363
|
+
continue
|
|
364
|
+
mc = update.get("content") or {}
|
|
365
|
+
if mc.get("type") != "text":
|
|
366
|
+
continue
|
|
367
|
+
text = mc.get("text", "")
|
|
368
|
+
for m in GROK_COMMAND_RE.finditer(text):
|
|
369
|
+
skill = m.group(1)
|
|
370
|
+
if skill in GROK_BUILTINS:
|
|
371
|
+
continue
|
|
372
|
+
seen.add(skill)
|
|
373
|
+
raw_ts = record.get("timestamp", 0)
|
|
374
|
+
ts = _epoch_to_iso(raw_ts) if isinstance(raw_ts, (int, float)) else ""
|
|
375
|
+
calls.append(SkillCall(
|
|
376
|
+
skill=skill, timestamp=ts, project=project,
|
|
377
|
+
session_id=params.get("sessionId", session_id),
|
|
378
|
+
source=self.name,
|
|
379
|
+
))
|
|
380
|
+
|
|
381
|
+
def _parse_chat(self, path: Path, project: str, session_id: str,
|
|
382
|
+
calls: list[SkillCall], seen: set[str]) -> None:
|
|
383
|
+
try:
|
|
384
|
+
lines = path.read_text().splitlines()
|
|
385
|
+
except OSError:
|
|
386
|
+
return
|
|
387
|
+
for line in lines:
|
|
388
|
+
if not line.strip() or "command-name" not in line:
|
|
389
|
+
continue
|
|
390
|
+
try:
|
|
391
|
+
record = json.loads(line)
|
|
392
|
+
except json.JSONDecodeError:
|
|
393
|
+
continue
|
|
394
|
+
if record.get("type") != "user":
|
|
395
|
+
continue
|
|
396
|
+
content = record.get("content", "")
|
|
397
|
+
if isinstance(content, list):
|
|
398
|
+
text = "".join(p.get("text", "") if isinstance(p, dict) else "" for p in content)
|
|
399
|
+
elif isinstance(content, str):
|
|
400
|
+
text = content
|
|
401
|
+
else:
|
|
402
|
+
continue
|
|
403
|
+
if "<background_context>" in text:
|
|
404
|
+
continue
|
|
405
|
+
for m in GROK_COMMAND_RE.finditer(text):
|
|
406
|
+
skill = m.group(1)
|
|
407
|
+
if skill in GROK_BUILTINS or skill in seen:
|
|
408
|
+
continue
|
|
409
|
+
seen.add(skill)
|
|
410
|
+
calls.append(SkillCall(
|
|
411
|
+
skill=skill, timestamp="", project=project,
|
|
412
|
+
session_id=session_id, source=self.name,
|
|
413
|
+
))
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# ── Droid CLI ───────────────────────────────────────────────────────────────
|
|
417
|
+
|
|
418
|
+
DROID_SESSIONS = HOME / ".factory" / "sessions"
|
|
419
|
+
DROID_SKILL_RE = re.compile(r'Skill "([^"]+)" is now active')
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class DroidProvider(Provider):
|
|
423
|
+
name = "Droid CLI"
|
|
424
|
+
|
|
425
|
+
def available(self) -> bool:
|
|
426
|
+
return DROID_SESSIONS.exists()
|
|
427
|
+
|
|
428
|
+
def collect(self) -> list[SkillCall]:
|
|
429
|
+
if not self.available():
|
|
430
|
+
return []
|
|
431
|
+
calls: list[SkillCall] = []
|
|
432
|
+
for fpath in DROID_SESSIONS.rglob("*.jsonl"):
|
|
433
|
+
self._parse_session(fpath, calls)
|
|
434
|
+
return calls
|
|
435
|
+
|
|
436
|
+
def _parse_session(self, path: Path, calls: list[SkillCall]) -> None:
|
|
437
|
+
try:
|
|
438
|
+
lines = path.read_text().splitlines()
|
|
439
|
+
except OSError:
|
|
440
|
+
return
|
|
441
|
+
session_id = ""
|
|
442
|
+
project = ""
|
|
443
|
+
for line in lines:
|
|
444
|
+
if not line.strip():
|
|
445
|
+
continue
|
|
446
|
+
try:
|
|
447
|
+
entry = json.loads(line)
|
|
448
|
+
except json.JSONDecodeError:
|
|
449
|
+
continue
|
|
450
|
+
if entry.get("type") == "session_start":
|
|
451
|
+
session_id = entry.get("id", "")
|
|
452
|
+
project = entry.get("cwd", "")
|
|
453
|
+
if entry.get("type") != "message":
|
|
454
|
+
continue
|
|
455
|
+
msg = entry.get("message") or {}
|
|
456
|
+
for part in msg.get("content", []):
|
|
457
|
+
if not isinstance(part, dict) or part.get("type") != "tool_result":
|
|
458
|
+
continue
|
|
459
|
+
text = part.get("content", "")
|
|
460
|
+
if not isinstance(text, str):
|
|
461
|
+
continue
|
|
462
|
+
m = DROID_SKILL_RE.search(text)
|
|
463
|
+
if not m:
|
|
464
|
+
continue
|
|
465
|
+
ts = entry.get("timestamp", "")
|
|
466
|
+
if isinstance(ts, str):
|
|
467
|
+
pass
|
|
468
|
+
else:
|
|
469
|
+
ts = _ms_to_iso(ts)
|
|
470
|
+
calls.append(SkillCall(
|
|
471
|
+
skill=m.group(1), timestamp=ts, project=project,
|
|
472
|
+
session_id=session_id, source=self.name,
|
|
473
|
+
))
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
# ── OpenCode ────────────────────────────────────────────────────────────────
|
|
477
|
+
|
|
478
|
+
OPENCODE_DB = HOME / ".local" / "share" / "opencode" / "opencode.db"
|
|
479
|
+
OPENCODE_BUILTINS = {
|
|
480
|
+
"bash", "compact", "help", "model", "config", "exit", "clear",
|
|
481
|
+
"status", "version", "approve", "settings", "list",
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
class OpenCodeProvider(Provider):
|
|
486
|
+
name = "OpenCode"
|
|
487
|
+
|
|
488
|
+
def available(self) -> bool:
|
|
489
|
+
return OPENCODE_DB.exists()
|
|
490
|
+
|
|
491
|
+
def collect(self) -> list[SkillCall]:
|
|
492
|
+
if not self.available():
|
|
493
|
+
return []
|
|
494
|
+
try:
|
|
495
|
+
conn = sqlite3.connect(OPENCODE_DB)
|
|
496
|
+
conn.row_factory = sqlite3.Row
|
|
497
|
+
except Exception:
|
|
498
|
+
return []
|
|
499
|
+
|
|
500
|
+
calls: list[SkillCall] = []
|
|
501
|
+
try:
|
|
502
|
+
rows = conn.execute(
|
|
503
|
+
"SELECT p.data, s.directory, p.session_id "
|
|
504
|
+
"FROM part p JOIN session s ON p.session_id = s.id "
|
|
505
|
+
"WHERE json_extract(p.data, '$.type') = 'tool' "
|
|
506
|
+
" AND json_extract(p.data, '$.tool') = 'skill'"
|
|
507
|
+
).fetchall()
|
|
508
|
+
for row in rows:
|
|
509
|
+
try:
|
|
510
|
+
data = json.loads(row["data"])
|
|
511
|
+
except (json.JSONDecodeError, TypeError):
|
|
512
|
+
continue
|
|
513
|
+
state = data.get("state") or {}
|
|
514
|
+
if state.get("status") != "completed":
|
|
515
|
+
continue
|
|
516
|
+
inp = state.get("input") or {}
|
|
517
|
+
skill = inp.get("name", "")
|
|
518
|
+
if not skill or skill in OPENCODE_BUILTINS:
|
|
519
|
+
continue
|
|
520
|
+
time_info = state.get("time") or {}
|
|
521
|
+
raw_ts = time_info.get("start", "")
|
|
522
|
+
ts = _ms_to_iso(raw_ts) if isinstance(raw_ts, (int, float)) else raw_ts
|
|
523
|
+
calls.append(SkillCall(
|
|
524
|
+
skill=skill, timestamp=ts,
|
|
525
|
+
project=row["directory"] or "",
|
|
526
|
+
session_id=row["session_id"] or "",
|
|
527
|
+
source=self.name,
|
|
528
|
+
))
|
|
529
|
+
except Exception:
|
|
530
|
+
pass
|
|
531
|
+
finally:
|
|
532
|
+
conn.close()
|
|
533
|
+
return calls
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# ── Skill Discovery ────────────────────────────────────────────────────────
|
|
537
|
+
|
|
538
|
+
SKILL_DIRS = [
|
|
539
|
+
HOME / ".agents" / "skills",
|
|
540
|
+
HOME / ".claude" / "skills",
|
|
541
|
+
HOME / ".copilot" / "skills",
|
|
542
|
+
HOME / ".copilot" / "installed-plugins",
|
|
543
|
+
HOME / ".claude" / "plugins",
|
|
544
|
+
]
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _discover_in_dir(base: Path) -> set[str]:
|
|
548
|
+
skills: set[str] = set()
|
|
549
|
+
if not base.exists():
|
|
550
|
+
return skills
|
|
551
|
+
if base.name == "skills":
|
|
552
|
+
for child in base.iterdir():
|
|
553
|
+
if child.is_dir() and not child.name.startswith("."):
|
|
554
|
+
skills.add(child.name)
|
|
555
|
+
return skills
|
|
556
|
+
for sd in base.rglob("skills"):
|
|
557
|
+
if sd.is_dir():
|
|
558
|
+
for child in sd.iterdir():
|
|
559
|
+
if child.is_dir() and not child.name.startswith("."):
|
|
560
|
+
skills.add(child.name)
|
|
561
|
+
for child in base.iterdir():
|
|
562
|
+
if child.is_dir() and not child.name.startswith(".") and any(child.glob("*.md")):
|
|
563
|
+
skills.add(child.name)
|
|
564
|
+
return skills
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def discover_installed_skills() -> list[str]:
|
|
568
|
+
all_skills: set[str] = set()
|
|
569
|
+
for d in SKILL_DIRS:
|
|
570
|
+
all_skills.update(_discover_in_dir(d))
|
|
571
|
+
return sorted(all_skills)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
@dataclass
|
|
575
|
+
class SkillHealth:
|
|
576
|
+
name: str
|
|
577
|
+
path: Path
|
|
578
|
+
has_skill_md: bool = False
|
|
579
|
+
has_description: bool = False
|
|
580
|
+
has_name_field: bool = False
|
|
581
|
+
file_size: int = 0
|
|
582
|
+
issues: list[str] = field(default_factory=list)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _find_skill_path(name: str) -> Path | None:
|
|
586
|
+
"""Locate the directory for a skill by name across all SKILL_DIRS."""
|
|
587
|
+
for base in SKILL_DIRS:
|
|
588
|
+
if not base.exists():
|
|
589
|
+
continue
|
|
590
|
+
# Direct child
|
|
591
|
+
candidate = base / name
|
|
592
|
+
if candidate.is_dir():
|
|
593
|
+
return candidate
|
|
594
|
+
# Nested under plugins/*/skills/
|
|
595
|
+
for sd in base.rglob("skills"):
|
|
596
|
+
if sd.is_dir():
|
|
597
|
+
candidate = sd / name
|
|
598
|
+
if candidate.is_dir():
|
|
599
|
+
return candidate
|
|
600
|
+
return None
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def check_skill_health(skills: list[str]) -> list[SkillHealth]:
|
|
604
|
+
"""Run health checks on all installed skills."""
|
|
605
|
+
results: list[SkillHealth] = []
|
|
606
|
+
for name in skills:
|
|
607
|
+
path = _find_skill_path(name)
|
|
608
|
+
if not path:
|
|
609
|
+
h = SkillHealth(name=name, path=Path("?"))
|
|
610
|
+
h.issues.append("directory not found")
|
|
611
|
+
results.append(h)
|
|
612
|
+
continue
|
|
613
|
+
|
|
614
|
+
h = SkillHealth(name=name, path=path)
|
|
615
|
+
|
|
616
|
+
# Check for SKILL.md
|
|
617
|
+
skill_md = path / "SKILL.md"
|
|
618
|
+
if skill_md.exists():
|
|
619
|
+
h.has_skill_md = True
|
|
620
|
+
h.file_size = skill_md.stat().st_size
|
|
621
|
+
content = skill_md.read_text(errors="replace")
|
|
622
|
+
|
|
623
|
+
# Parse YAML frontmatter
|
|
624
|
+
if content.startswith("---"):
|
|
625
|
+
parts = content.split("---", 2)
|
|
626
|
+
if len(parts) >= 3:
|
|
627
|
+
fm = parts[1]
|
|
628
|
+
if re.search(r"^name\s*:", fm, re.MULTILINE):
|
|
629
|
+
h.has_name_field = True
|
|
630
|
+
if re.search(r"^description\s*:", fm, re.MULTILINE):
|
|
631
|
+
h.has_description = True
|
|
632
|
+
|
|
633
|
+
if not h.has_name_field:
|
|
634
|
+
h.issues.append("missing 'name' in frontmatter")
|
|
635
|
+
if not h.has_description:
|
|
636
|
+
h.issues.append("missing 'description' in frontmatter")
|
|
637
|
+
if h.file_size < 50:
|
|
638
|
+
h.issues.append("SKILL.md too short (<50 bytes)")
|
|
639
|
+
else:
|
|
640
|
+
# Maybe has README.md instead
|
|
641
|
+
readme = path / "README.md"
|
|
642
|
+
if readme.exists():
|
|
643
|
+
h.issues.append("has README.md but no SKILL.md")
|
|
644
|
+
else:
|
|
645
|
+
h.issues.append("no SKILL.md or README.md")
|
|
646
|
+
|
|
647
|
+
results.append(h)
|
|
648
|
+
return results
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def render_health_check(results: list[SkillHealth]) -> None:
|
|
652
|
+
"""Render health check results — only show issues."""
|
|
653
|
+
problems = [h for h in results if h.issues]
|
|
654
|
+
healthy = len(results) - len(problems)
|
|
655
|
+
|
|
656
|
+
if not problems:
|
|
657
|
+
console.print(f" [green]✓[/green] Health check: all [bold]{healthy}[/bold] skills are well-formed")
|
|
658
|
+
return
|
|
659
|
+
|
|
660
|
+
console.print(f" [yellow]⚠[/yellow] Health check: [bold green]{healthy}[/bold green] healthy, [bold yellow]{len(problems)}[/bold yellow] with issues")
|
|
661
|
+
|
|
662
|
+
table = Table(
|
|
663
|
+
box=box.SIMPLE, show_header=True, header_style="bold",
|
|
664
|
+
border_style="bright_black", padding=(0, 1),
|
|
665
|
+
)
|
|
666
|
+
table.add_column("Skill", style="cyan", min_width=20)
|
|
667
|
+
table.add_column("Issues", style="yellow")
|
|
668
|
+
|
|
669
|
+
for h in problems[:15]:
|
|
670
|
+
table.add_row(h.name, "; ".join(h.issues))
|
|
671
|
+
|
|
672
|
+
if len(problems) > 15:
|
|
673
|
+
table.add_row(f"… +{len(problems) - 15} more", "", style="dim")
|
|
674
|
+
|
|
675
|
+
console.print(table)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
# ── Helpers ─────────────────────────────────────────────────────────────────
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _ms_to_iso(ms: int | float) -> str:
|
|
682
|
+
try:
|
|
683
|
+
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
684
|
+
except (OSError, ValueError, OverflowError):
|
|
685
|
+
return ""
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _epoch_to_iso(s: int | float) -> str:
|
|
689
|
+
try:
|
|
690
|
+
return datetime.fromtimestamp(s, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
691
|
+
except (OSError, ValueError, OverflowError):
|
|
692
|
+
return ""
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _fmt_date(iso: str) -> str:
|
|
696
|
+
if not iso:
|
|
697
|
+
return "—"
|
|
698
|
+
try:
|
|
699
|
+
dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
|
700
|
+
return dt.strftime("%Y-%m-%d")
|
|
701
|
+
except ValueError:
|
|
702
|
+
return iso[:10] if len(iso) >= 10 else iso
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
# ── Report Builder ──────────────────────────────────────────────────────────
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
ALL_PROVIDERS: list[Provider] = [
|
|
709
|
+
CopilotProvider(),
|
|
710
|
+
ClaudeCodeProvider(),
|
|
711
|
+
CodexProvider(),
|
|
712
|
+
OpenCodeProvider(),
|
|
713
|
+
GrokProvider(),
|
|
714
|
+
DroidProvider(),
|
|
715
|
+
]
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def build_report(
|
|
719
|
+
calls: list[SkillCall],
|
|
720
|
+
installed_skills: list[str],
|
|
721
|
+
sources: list[str],
|
|
722
|
+
days: int | None,
|
|
723
|
+
) -> dict:
|
|
724
|
+
# Filter by time window
|
|
725
|
+
if days is not None:
|
|
726
|
+
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
727
|
+
calls = [c for c in calls if c.timestamp >= cutoff]
|
|
728
|
+
|
|
729
|
+
sc: Counter[str] = Counter()
|
|
730
|
+
ss: defaultdict[str, set[str]] = defaultdict(set)
|
|
731
|
+
sf: dict[str, str] = {}
|
|
732
|
+
sl: dict[str, str] = {}
|
|
733
|
+
sp: defaultdict[str, set[str]] = defaultdict(set)
|
|
734
|
+
s_sources: defaultdict[str, set[str]] = defaultdict(set)
|
|
735
|
+
sessions: set[str] = set()
|
|
736
|
+
|
|
737
|
+
for c in calls:
|
|
738
|
+
sc[c.skill] += 1
|
|
739
|
+
ss[c.skill].add(c.session_id)
|
|
740
|
+
sp[c.skill].add(c.project)
|
|
741
|
+
s_sources[c.skill].add(c.source)
|
|
742
|
+
sessions.add(c.session_id)
|
|
743
|
+
ts = str(c.timestamp) if c.timestamp else ""
|
|
744
|
+
if ts:
|
|
745
|
+
if c.skill not in sf or ts < sf[c.skill]:
|
|
746
|
+
sf[c.skill] = ts
|
|
747
|
+
if c.skill not in sl or ts > sl[c.skill]:
|
|
748
|
+
sl[c.skill] = ts
|
|
749
|
+
|
|
750
|
+
items = []
|
|
751
|
+
for name in installed_skills:
|
|
752
|
+
items.append({
|
|
753
|
+
"skill_name": name,
|
|
754
|
+
"load_count": sc.get(name, 0),
|
|
755
|
+
"session_count": len(ss.get(name, set())),
|
|
756
|
+
"project_count": len(sp.get(name, set())),
|
|
757
|
+
"sources": sorted(s_sources.get(name, set())),
|
|
758
|
+
"first_seen": sf.get(name, ""),
|
|
759
|
+
"last_seen": sl.get(name, ""),
|
|
760
|
+
})
|
|
761
|
+
for name in sc:
|
|
762
|
+
if name not in installed_skills:
|
|
763
|
+
items.append({
|
|
764
|
+
"skill_name": f"{name} (?)",
|
|
765
|
+
"load_count": sc[name],
|
|
766
|
+
"session_count": len(ss[name]),
|
|
767
|
+
"project_count": len(sp[name]),
|
|
768
|
+
"sources": sorted(s_sources[name]),
|
|
769
|
+
"first_seen": sf.get(name, ""),
|
|
770
|
+
"last_seen": sl.get(name, ""),
|
|
771
|
+
})
|
|
772
|
+
items.sort(key=lambda x: (-x["load_count"], -x["session_count"], x["skill_name"]))
|
|
773
|
+
|
|
774
|
+
used = sum(1 for i in items if i["load_count"] > 0)
|
|
775
|
+
unused = sum(1 for i in items if i["load_count"] == 0)
|
|
776
|
+
return {
|
|
777
|
+
"sources": sources,
|
|
778
|
+
"window": "all time" if days is None else f"last {days} days",
|
|
779
|
+
"total_sessions": len(sessions),
|
|
780
|
+
"total_events": sum(sc.values()),
|
|
781
|
+
"installed": len(installed_skills),
|
|
782
|
+
"used": used,
|
|
783
|
+
"unused": unused,
|
|
784
|
+
"skills": items,
|
|
785
|
+
"calls": calls, # keep raw calls for audit & heatmap
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
# ── Skill Audit ─────────────────────────────────────────────────────────────
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
@dataclass
|
|
793
|
+
class _SkillSummary:
|
|
794
|
+
name: str
|
|
795
|
+
count: int
|
|
796
|
+
sessions: int
|
|
797
|
+
projects: int
|
|
798
|
+
last_used: str # ISO
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
@dataclass
|
|
802
|
+
class TrendEntry:
|
|
803
|
+
skill: _SkillSummary
|
|
804
|
+
recent: int
|
|
805
|
+
prior: int
|
|
806
|
+
pct: int # positive = rising, negative = declining
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
@dataclass
|
|
810
|
+
class SkillAudit:
|
|
811
|
+
most_used: list[tuple[_SkillSummary, float]] # (skill, share%)
|
|
812
|
+
rising: list[TrendEntry]
|
|
813
|
+
declining: list[TrendEntry]
|
|
814
|
+
stale: list[_SkillSummary]
|
|
815
|
+
one_off: list[_SkillSummary]
|
|
816
|
+
cross_project: list[_SkillSummary]
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _parse_ts(iso: str) -> datetime | None:
|
|
820
|
+
if not iso:
|
|
821
|
+
return None
|
|
822
|
+
try:
|
|
823
|
+
return datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
|
824
|
+
except ValueError:
|
|
825
|
+
return None
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def audit_skills(calls: list[SkillCall], report_skills: list[dict]) -> SkillAudit:
|
|
829
|
+
"""Classify skills into audit categories based on usage trends."""
|
|
830
|
+
now = datetime.now(timezone.utc)
|
|
831
|
+
four_weeks_ago = (now - timedelta(days=28)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
832
|
+
eight_weeks_ago = (now - timedelta(days=56)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
833
|
+
|
|
834
|
+
# Build SkillSummary from report items
|
|
835
|
+
summaries: dict[str, _SkillSummary] = {}
|
|
836
|
+
for item in report_skills:
|
|
837
|
+
if item["load_count"] == 0:
|
|
838
|
+
continue
|
|
839
|
+
summaries[item["skill_name"]] = _SkillSummary(
|
|
840
|
+
name=item["skill_name"],
|
|
841
|
+
count=item["load_count"],
|
|
842
|
+
sessions=item["session_count"],
|
|
843
|
+
projects=item["project_count"],
|
|
844
|
+
last_used=item["last_seen"],
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
# Count recent (0-4w) and prior (4-8w) calls per skill
|
|
848
|
+
recent_counts: Counter[str] = Counter()
|
|
849
|
+
prior_counts: Counter[str] = Counter()
|
|
850
|
+
for c in calls:
|
|
851
|
+
ts = str(c.timestamp) if c.timestamp else ""
|
|
852
|
+
if not ts:
|
|
853
|
+
continue
|
|
854
|
+
if ts >= four_weeks_ago:
|
|
855
|
+
recent_counts[c.skill] += 1
|
|
856
|
+
elif ts >= eight_weeks_ago:
|
|
857
|
+
prior_counts[c.skill] += 1
|
|
858
|
+
|
|
859
|
+
skills = list(summaries.values())
|
|
860
|
+
|
|
861
|
+
# One-off: used exactly once
|
|
862
|
+
one_off = [s for s in skills if s.count == 1]
|
|
863
|
+
one_off_set = {s.name for s in one_off}
|
|
864
|
+
|
|
865
|
+
# Stale: last used > 4 weeks ago, not one-off
|
|
866
|
+
stale = [
|
|
867
|
+
s for s in skills
|
|
868
|
+
if s.name not in one_off_set and s.last_used and s.last_used < four_weeks_ago
|
|
869
|
+
]
|
|
870
|
+
stale_set = {s.name for s in stale}
|
|
871
|
+
|
|
872
|
+
# Rising: recent >= 1.5× prior
|
|
873
|
+
rising: list[TrendEntry] = []
|
|
874
|
+
for s in skills:
|
|
875
|
+
if s.name in stale_set or s.name in one_off_set:
|
|
876
|
+
continue
|
|
877
|
+
rc = recent_counts.get(s.name, 0)
|
|
878
|
+
pc = prior_counts.get(s.name, 0)
|
|
879
|
+
if pc > 0 and rc >= pc * 1.5:
|
|
880
|
+
pct = round((rc / pc - 1) * 100)
|
|
881
|
+
rising.append(TrendEntry(skill=s, recent=rc, prior=pc, pct=pct))
|
|
882
|
+
rising.sort(key=lambda t: -t.pct)
|
|
883
|
+
|
|
884
|
+
# Declining: recent <= 0.5× prior
|
|
885
|
+
declining: list[TrendEntry] = []
|
|
886
|
+
for s in skills:
|
|
887
|
+
if s.name in stale_set or s.name in one_off_set:
|
|
888
|
+
continue
|
|
889
|
+
rc = recent_counts.get(s.name, 0)
|
|
890
|
+
pc = prior_counts.get(s.name, 0)
|
|
891
|
+
if pc > 0 and rc <= pc * 0.5:
|
|
892
|
+
pct = round((1 - rc / pc) * 100)
|
|
893
|
+
declining.append(TrendEntry(skill=s, recent=rc, prior=pc, pct=pct))
|
|
894
|
+
declining.sort(key=lambda t: -t.pct)
|
|
895
|
+
|
|
896
|
+
rising_set = {t.skill.name for t in rising}
|
|
897
|
+
declining_set = {t.skill.name for t in declining}
|
|
898
|
+
|
|
899
|
+
# Most used (last 4 weeks), excluding already-classified
|
|
900
|
+
recent_total = sum(recent_counts.values()) or 1
|
|
901
|
+
most_used: list[tuple[_SkillSummary, float]] = []
|
|
902
|
+
for s in sorted(skills, key=lambda s: -recent_counts.get(s.name, 0)):
|
|
903
|
+
if s.name in stale_set or s.name in one_off_set or s.name in rising_set or s.name in declining_set:
|
|
904
|
+
continue
|
|
905
|
+
rc = recent_counts.get(s.name, 0)
|
|
906
|
+
if rc > 0:
|
|
907
|
+
most_used.append((s, rc / recent_total))
|
|
908
|
+
most_used = most_used[:10]
|
|
909
|
+
|
|
910
|
+
# Cross-project: used in 3+ projects
|
|
911
|
+
cross_project = sorted([s for s in skills if s.projects >= 3], key=lambda s: -s.projects)
|
|
912
|
+
|
|
913
|
+
return SkillAudit(
|
|
914
|
+
most_used=most_used,
|
|
915
|
+
rising=rising,
|
|
916
|
+
declining=declining,
|
|
917
|
+
stale=stale,
|
|
918
|
+
one_off=one_off,
|
|
919
|
+
cross_project=cross_project,
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
# ── Rich Rendering ──────────────────────────────────────────────────────────
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def render_report(report: dict, limit: int = 50, show_unused: bool = False) -> None:
|
|
927
|
+
src_label = ", ".join(report["sources"]) or "none"
|
|
928
|
+
|
|
929
|
+
header = Text()
|
|
930
|
+
header.append(" Sources: ", style="dim")
|
|
931
|
+
header.append(f"{src_label}\n", style="bold cyan")
|
|
932
|
+
header.append(" Window: ", style="dim")
|
|
933
|
+
header.append(f"{report['window']}\n", style="bold")
|
|
934
|
+
header.append(" Sessions: ", style="dim")
|
|
935
|
+
header.append(f"{report['total_sessions']}\n", style="bold")
|
|
936
|
+
header.append(" Skills: ", style="dim")
|
|
937
|
+
header.append(f"{report['installed']}", style="bold")
|
|
938
|
+
header.append(" installed, ", style="dim")
|
|
939
|
+
header.append(f"{report['used']}", style="bold green")
|
|
940
|
+
header.append(" used, ", style="dim")
|
|
941
|
+
header.append(f"{report['unused']}", style="bold yellow")
|
|
942
|
+
header.append(" unused\n", style="dim")
|
|
943
|
+
header.append(" Events: ", style="dim")
|
|
944
|
+
header.append(f"{report['total_events']}", style="bold magenta")
|
|
945
|
+
header.append(" total skill loads", style="dim")
|
|
946
|
+
|
|
947
|
+
console.print()
|
|
948
|
+
console.print(Panel(header, title="[bold]📊 Skill Usage Report[/bold]", border_style="blue", padding=(0, 1)))
|
|
949
|
+
|
|
950
|
+
all_skills = report["skills"]
|
|
951
|
+
used_skills = [s for s in all_skills if s["load_count"] > 0][:limit]
|
|
952
|
+
unused_skills = [s for s in all_skills if s["load_count"] == 0]
|
|
953
|
+
|
|
954
|
+
if not used_skills:
|
|
955
|
+
console.print("\n[yellow] No skill records found.[/yellow]")
|
|
956
|
+
return
|
|
957
|
+
|
|
958
|
+
_render_skill_table(used_skills, title="Skill Breakdown")
|
|
959
|
+
|
|
960
|
+
# ── Audit ──
|
|
961
|
+
calls = report.get("calls", [])
|
|
962
|
+
if used_skills:
|
|
963
|
+
audit = audit_skills(calls, report["skills"])
|
|
964
|
+
render_audit(audit)
|
|
965
|
+
|
|
966
|
+
# ── Unused skills ──
|
|
967
|
+
if unused_skills and show_unused:
|
|
968
|
+
_render_unused_list(unused_skills)
|
|
969
|
+
elif unused_skills and not show_unused:
|
|
970
|
+
console.print(f"\n [dim]{len(unused_skills)} unused skills hidden.[/dim]")
|
|
971
|
+
|
|
972
|
+
console.print()
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _render_skill_table(skills: list[dict], title: str = "Skill Breakdown") -> None:
|
|
976
|
+
"""Render the skill usage table (only for skills with loads > 0)."""
|
|
977
|
+
table = Table(
|
|
978
|
+
box=box.ROUNDED, show_header=True, header_style="bold white",
|
|
979
|
+
border_style="bright_black", row_styles=["", "dim"],
|
|
980
|
+
padding=(0, 1), title=f"[bold]{title}[/bold]", title_style="bold",
|
|
981
|
+
)
|
|
982
|
+
table.add_column("#", style="dim", width=3, justify="right")
|
|
983
|
+
table.add_column("Skill", style="cyan", min_width=18)
|
|
984
|
+
table.add_column("Loads", justify="right", style="bold")
|
|
985
|
+
table.add_column("Sessions", justify="right")
|
|
986
|
+
table.add_column("Projects", justify="right")
|
|
987
|
+
table.add_column("Source", min_width=10)
|
|
988
|
+
table.add_column("First", justify="center")
|
|
989
|
+
table.add_column("Last", justify="center")
|
|
990
|
+
table.add_column("", width=10)
|
|
991
|
+
|
|
992
|
+
max_loads = max((s["load_count"] for s in skills), default=1) or 1
|
|
993
|
+
for i, skill in enumerate(skills, 1):
|
|
994
|
+
loads = skill["load_count"]
|
|
995
|
+
bar_w = int((loads / max_loads) * 8) if loads > 0 else 0
|
|
996
|
+
bar = "█" * bar_w + "░" * (8 - bar_w)
|
|
997
|
+
|
|
998
|
+
if loads >= 5:
|
|
999
|
+
ls, bs, ns = "bold green", "green", "bold cyan"
|
|
1000
|
+
elif loads >= 2:
|
|
1001
|
+
ls, bs, ns = "bold yellow", "yellow", "cyan"
|
|
1002
|
+
else:
|
|
1003
|
+
ls, bs, ns = "white", "blue", "cyan"
|
|
1004
|
+
|
|
1005
|
+
src = ", ".join(skill["sources"]) if skill["sources"] else "—"
|
|
1006
|
+
table.add_row(
|
|
1007
|
+
str(i),
|
|
1008
|
+
Text(skill["skill_name"], style=ns),
|
|
1009
|
+
Text(str(loads), style=ls),
|
|
1010
|
+
Text(str(skill["session_count"]), style="white"),
|
|
1011
|
+
Text(str(skill["project_count"]), style="white"),
|
|
1012
|
+
Text(src, style="dim cyan"),
|
|
1013
|
+
Text(_fmt_date(skill["first_seen"]), style="white"),
|
|
1014
|
+
Text(_fmt_date(skill["last_seen"]), style="white"),
|
|
1015
|
+
Text(bar, style=bs),
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
console.print()
|
|
1019
|
+
console.print(table)
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def _render_unused_list(unused_skills: list[dict]) -> None:
|
|
1023
|
+
"""Render a compact list of unused (0-load) skills."""
|
|
1024
|
+
cols = 4
|
|
1025
|
+
names = [s["skill_name"] for s in unused_skills]
|
|
1026
|
+
t = Text()
|
|
1027
|
+
for i, name in enumerate(names):
|
|
1028
|
+
t.append(f" {name:<26}", style="dim")
|
|
1029
|
+
if (i + 1) % cols == 0:
|
|
1030
|
+
t.append("\n")
|
|
1031
|
+
console.print()
|
|
1032
|
+
console.print(Panel(
|
|
1033
|
+
t,
|
|
1034
|
+
title=f"[bold yellow]💤 Unused Skills ({len(names)})[/bold yellow]",
|
|
1035
|
+
border_style="yellow",
|
|
1036
|
+
padding=(0, 1),
|
|
1037
|
+
))
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def _fit(s: str, n: int) -> str:
|
|
1041
|
+
return (s[:n - 1] + "…") if len(s) > n else s.ljust(n)
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def render_audit(audit: SkillAudit) -> None:
|
|
1045
|
+
"""Render the skill audit analysis."""
|
|
1046
|
+
sections: list[Text] = []
|
|
1047
|
+
|
|
1048
|
+
# ⭐ Most Used
|
|
1049
|
+
if audit.most_used:
|
|
1050
|
+
t = Text()
|
|
1051
|
+
t.append(" ⭐ MOST USED ", style="bold white")
|
|
1052
|
+
t.append("— last 4 weeks\n", style="dim")
|
|
1053
|
+
for skill, share in audit.most_used:
|
|
1054
|
+
pct = f"{share * 100:.0f}%".rjust(4)
|
|
1055
|
+
t.append(f" {_fit(skill.name, 22)} {pct} ", style="cyan")
|
|
1056
|
+
t.append(f"{skill.count} calls {skill.projects} proj\n", style="dim")
|
|
1057
|
+
sections.append(t)
|
|
1058
|
+
|
|
1059
|
+
# ▲ Rising
|
|
1060
|
+
if audit.rising:
|
|
1061
|
+
t = Text()
|
|
1062
|
+
t.append(" ▲ RISING ", style="bold green")
|
|
1063
|
+
t.append("— 50%+ growth last 4w\n", style="dim")
|
|
1064
|
+
for entry in audit.rising:
|
|
1065
|
+
t.append(f" {_fit(entry.skill.name, 22)} ", style="green")
|
|
1066
|
+
t.append(f"now: {entry.recent:3d} was: {entry.prior:3d} ", style="dim")
|
|
1067
|
+
t.append(f"↑{entry.pct}%\n", style="bold green")
|
|
1068
|
+
sections.append(t)
|
|
1069
|
+
|
|
1070
|
+
# ▼ Declining
|
|
1071
|
+
if audit.declining:
|
|
1072
|
+
t = Text()
|
|
1073
|
+
t.append(" ▼ DECLINING ", style="bold red")
|
|
1074
|
+
t.append("— 50%+ drop last 4w\n", style="dim")
|
|
1075
|
+
for entry in audit.declining:
|
|
1076
|
+
t.append(f" {_fit(entry.skill.name, 22)} ", style="red")
|
|
1077
|
+
t.append(f"now: {entry.recent:3d} was: {entry.prior:3d} ", style="dim")
|
|
1078
|
+
t.append(f"↓{entry.pct}%\n", style="bold red")
|
|
1079
|
+
sections.append(t)
|
|
1080
|
+
|
|
1081
|
+
# ⚠ Stale
|
|
1082
|
+
if audit.stale:
|
|
1083
|
+
t = Text()
|
|
1084
|
+
t.append(" ⚠ STALE ", style="bold yellow")
|
|
1085
|
+
t.append(f"— unused 28+ days ({len(audit.stale)})\n", style="dim")
|
|
1086
|
+
for s in audit.stale[:8]:
|
|
1087
|
+
last = _fmt_date(s.last_used)
|
|
1088
|
+
t.append(f" {_fit(s.name, 22)} last: {last} {s.count} calls\n", style="yellow")
|
|
1089
|
+
if len(audit.stale) > 8:
|
|
1090
|
+
t.append(f" … and {len(audit.stale) - 8} more\n", style="dim")
|
|
1091
|
+
sections.append(t)
|
|
1092
|
+
|
|
1093
|
+
# ◈ Cross-project
|
|
1094
|
+
if audit.cross_project:
|
|
1095
|
+
t = Text()
|
|
1096
|
+
t.append(" ◈ CROSS-PROJECT ", style="bold cyan")
|
|
1097
|
+
t.append(f"— used in 3+ projects ({len(audit.cross_project)})\n", style="dim")
|
|
1098
|
+
for s in audit.cross_project[:8]:
|
|
1099
|
+
t.append(f" {_fit(s.name, 22)} {s.projects} projects {s.count} calls\n", style="cyan")
|
|
1100
|
+
sections.append(t)
|
|
1101
|
+
|
|
1102
|
+
# ◇ One-off
|
|
1103
|
+
if audit.one_off:
|
|
1104
|
+
t = Text()
|
|
1105
|
+
t.append(" ◇ ONE-OFF ", style="dim bold")
|
|
1106
|
+
t.append(f"— used once ({len(audit.one_off)})\n", style="dim")
|
|
1107
|
+
for s in audit.one_off[:6]:
|
|
1108
|
+
t.append(f" {_fit(s.name, 22)} {_fmt_date(s.last_used)}\n", style="dim")
|
|
1109
|
+
if len(audit.one_off) > 6:
|
|
1110
|
+
t.append(f" … and {len(audit.one_off) - 6} more\n", style="dim")
|
|
1111
|
+
sections.append(t)
|
|
1112
|
+
|
|
1113
|
+
if not sections:
|
|
1114
|
+
return
|
|
1115
|
+
|
|
1116
|
+
combined = Text()
|
|
1117
|
+
for i, sec in enumerate(sections):
|
|
1118
|
+
if i > 0:
|
|
1119
|
+
combined.append("\n")
|
|
1120
|
+
combined.append_text(sec)
|
|
1121
|
+
|
|
1122
|
+
console.print()
|
|
1123
|
+
console.print(Panel(
|
|
1124
|
+
combined,
|
|
1125
|
+
title="[bold]🔍 Skill Audit[/bold]",
|
|
1126
|
+
border_style="magenta",
|
|
1127
|
+
padding=(0, 1),
|
|
1128
|
+
))
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
# ── CLI ─────────────────────────────────────────────────────────────────────
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def parse_args() -> argparse.Namespace:
|
|
1135
|
+
p = argparse.ArgumentParser(description="Skill Usage Stats — scan agent sessions for skill usage.")
|
|
1136
|
+
p.add_argument("--source", nargs="*", help="Filter by source (copilot, claude, codex, opencode, grok, droid)")
|
|
1137
|
+
p.add_argument("--days", type=int, default=None, help="Look back N days (0 or omit = all)")
|
|
1138
|
+
p.add_argument("--all", action="store_true", help="Scan all time")
|
|
1139
|
+
p.add_argument("--limit", type=int, default=50, help="Max rows (default: 50)")
|
|
1140
|
+
p.add_argument("--json", action="store_true", help="Output JSON")
|
|
1141
|
+
return p.parse_args()
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
SOURCE_KEY_MAP = {
|
|
1145
|
+
"copilot": "GitHub Copilot",
|
|
1146
|
+
"claude": "Claude Code",
|
|
1147
|
+
"codex": "Codex CLI",
|
|
1148
|
+
"opencode": "OpenCode",
|
|
1149
|
+
"grok": "Grok CLI",
|
|
1150
|
+
"droid": "Droid CLI",
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def main() -> int:
|
|
1155
|
+
args = parse_args()
|
|
1156
|
+
|
|
1157
|
+
console.print()
|
|
1158
|
+
console.print("[bold blue]⚡ Skill Usage Stats[/bold blue]")
|
|
1159
|
+
console.print("[dim] Scan your agent sessions and discover skill usage patterns[/dim]")
|
|
1160
|
+
|
|
1161
|
+
# 1. Detect available providers
|
|
1162
|
+
available = [p for p in ALL_PROVIDERS if p.available()]
|
|
1163
|
+
if not available:
|
|
1164
|
+
console.print("\n[bold red]✗ No supported agent data found on this machine.[/bold red]")
|
|
1165
|
+
return 1
|
|
1166
|
+
|
|
1167
|
+
# 2. Determine if interactive
|
|
1168
|
+
is_interactive = args.source is None and not args.json
|
|
1169
|
+
|
|
1170
|
+
# 3. Select sources
|
|
1171
|
+
if args.source is not None:
|
|
1172
|
+
selected_names = {SOURCE_KEY_MAP.get(s.lower(), s) for s in args.source}
|
|
1173
|
+
providers = [p for p in available if p.name in selected_names]
|
|
1174
|
+
elif len(available) == 1:
|
|
1175
|
+
providers = available
|
|
1176
|
+
else:
|
|
1177
|
+
console.print()
|
|
1178
|
+
choices = [{"name": f"{p.name}", "value": p.name} for p in available]
|
|
1179
|
+
choices.insert(0, {"name": "All detected sources", "value": "__all__"})
|
|
1180
|
+
selected = inquirer.select(
|
|
1181
|
+
message="Which source(s)?",
|
|
1182
|
+
choices=choices,
|
|
1183
|
+
default="__all__",
|
|
1184
|
+
style=PROMPT_STYLE,
|
|
1185
|
+
).execute()
|
|
1186
|
+
providers = available if selected == "__all__" else [p for p in available if p.name == selected]
|
|
1187
|
+
|
|
1188
|
+
# 4. Time window
|
|
1189
|
+
if args.all:
|
|
1190
|
+
days = None
|
|
1191
|
+
elif args.days is not None:
|
|
1192
|
+
days = args.days if args.days > 0 else None
|
|
1193
|
+
elif args.source is None:
|
|
1194
|
+
console.print()
|
|
1195
|
+
window = inquirer.select(
|
|
1196
|
+
message="Time window?",
|
|
1197
|
+
choices=[
|
|
1198
|
+
{"name": "All time", "value": 0},
|
|
1199
|
+
{"name": "Last 7 days", "value": 7},
|
|
1200
|
+
{"name": "Last 14 days", "value": 14},
|
|
1201
|
+
{"name": "Last 30 days", "value": 30},
|
|
1202
|
+
{"name": "Last 90 days", "value": 90},
|
|
1203
|
+
],
|
|
1204
|
+
default=0,
|
|
1205
|
+
style=PROMPT_STYLE,
|
|
1206
|
+
).execute()
|
|
1207
|
+
days = window if window > 0 else None
|
|
1208
|
+
else:
|
|
1209
|
+
days = None
|
|
1210
|
+
|
|
1211
|
+
# 4. Discover skills + health check
|
|
1212
|
+
with console.status("[bold cyan]Discovering installed skills...", spinner="dots"):
|
|
1213
|
+
installed_skills = discover_installed_skills()
|
|
1214
|
+
console.print(f" [green]✓[/green] Found [bold]{len(installed_skills)}[/bold] installed skills")
|
|
1215
|
+
|
|
1216
|
+
with console.status("[bold cyan]Running health check...", spinner="dots"):
|
|
1217
|
+
health_results = check_skill_health(installed_skills)
|
|
1218
|
+
render_health_check(health_results)
|
|
1219
|
+
|
|
1220
|
+
# 4b. Ask whether to show unused skills (interactive only)
|
|
1221
|
+
show_unused = False
|
|
1222
|
+
if is_interactive:
|
|
1223
|
+
console.print()
|
|
1224
|
+
show_unused = inquirer.confirm(
|
|
1225
|
+
message="Include never-used skills in the report?",
|
|
1226
|
+
default=False,
|
|
1227
|
+
style=PROMPT_STYLE,
|
|
1228
|
+
).execute()
|
|
1229
|
+
|
|
1230
|
+
# 5. Collect calls
|
|
1231
|
+
all_calls: list[SkillCall] = []
|
|
1232
|
+
source_names: list[str] = []
|
|
1233
|
+
for p in providers:
|
|
1234
|
+
with console.status(f"[bold cyan]Scanning {p.name}...", spinner="dots"):
|
|
1235
|
+
calls = p.collect()
|
|
1236
|
+
console.print(f" [green]✓[/green] {p.name}: [bold]{len(calls)}[/bold] skill events")
|
|
1237
|
+
all_calls.extend(calls)
|
|
1238
|
+
source_names.append(p.name)
|
|
1239
|
+
|
|
1240
|
+
# 6. Build & render report
|
|
1241
|
+
report = build_report(all_calls, installed_skills, source_names, days)
|
|
1242
|
+
|
|
1243
|
+
if args.json:
|
|
1244
|
+
console.print()
|
|
1245
|
+
json_report = {k: v for k, v in report.items() if k != "calls"}
|
|
1246
|
+
console.print_json(json.dumps(json_report, ensure_ascii=False))
|
|
1247
|
+
else:
|
|
1248
|
+
render_report(report, args.limit, show_unused=show_unused)
|
|
1249
|
+
|
|
1250
|
+
return 0
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
if __name__ == "__main__":
|
|
1254
|
+
raise SystemExit(main())
|