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/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())