skillvitals 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.
- skillvitals/__init__.py +3 -0
- skillvitals/__main__.py +4 -0
- skillvitals/analysis.py +139 -0
- skillvitals/cli.py +283 -0
- skillvitals/config.py +73 -0
- skillvitals/dashboard.py +84 -0
- skillvitals/hooks.py +49 -0
- skillvitals/logparser.py +196 -0
- skillvitals/models.py +158 -0
- skillvitals/pipeline.py +58 -0
- skillvitals/prescribe.py +148 -0
- skillvitals/registry.py +210 -0
- skillvitals/report.py +107 -0
- skillvitals/server.py +125 -0
- skillvitals/storage.py +158 -0
- skillvitals/templates/dashboard.html.j2 +113 -0
- skillvitals/testharness.py +145 -0
- skillvitals/tokens.py +26 -0
- skillvitals-0.1.0.dist-info/METADATA +178 -0
- skillvitals-0.1.0.dist-info/RECORD +23 -0
- skillvitals-0.1.0.dist-info/WHEEL +4 -0
- skillvitals-0.1.0.dist-info/entry_points.txt +2 -0
- skillvitals-0.1.0.dist-info/licenses/LICENSE +21 -0
skillvitals/__init__.py
ADDED
skillvitals/__main__.py
ADDED
skillvitals/analysis.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Analysis engine: join registry skills with fire history into SkillVitals.
|
|
2
|
+
|
|
3
|
+
This is where the health model lives. Definitions (documented for honesty —
|
|
4
|
+
these are heuristics, not ground truth):
|
|
5
|
+
|
|
6
|
+
never-fired : zero lifetime activations.
|
|
7
|
+
dormant : has activated before, but not within the window.
|
|
8
|
+
misfiring : activated in-window, explicitly invoked, but with low
|
|
9
|
+
follow-through (engagement_ratio < MISFIRE_THRESHOLD) — a proxy
|
|
10
|
+
for wrong-prompt activation (fired, then abandoned).
|
|
11
|
+
healthy : activated in-window with adequate engagement.
|
|
12
|
+
orphan : appears in logs but is not installed (uninstalled/renamed).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from datetime import UTC, datetime, timedelta
|
|
18
|
+
|
|
19
|
+
from .models import Fire, FireKind, Health, Skill, SkillVitals
|
|
20
|
+
|
|
21
|
+
MISFIRE_THRESHOLD = 2.0 # attribution messages per invoke below this looks like a misfire
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _classify(
|
|
25
|
+
*,
|
|
26
|
+
has_skill: bool,
|
|
27
|
+
total_activity: int,
|
|
28
|
+
window_activity: int,
|
|
29
|
+
invoke_count: int,
|
|
30
|
+
engagement_ratio: float,
|
|
31
|
+
) -> Health:
|
|
32
|
+
if not has_skill:
|
|
33
|
+
return Health.ORPHAN
|
|
34
|
+
if total_activity == 0:
|
|
35
|
+
return Health.NEVER_FIRED
|
|
36
|
+
if window_activity == 0:
|
|
37
|
+
return Health.DORMANT
|
|
38
|
+
if invoke_count > 0 and engagement_ratio < MISFIRE_THRESHOLD:
|
|
39
|
+
return Health.MISFIRING
|
|
40
|
+
return Health.HEALTHY
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _tz(fires: list[Fire]):
|
|
44
|
+
for f in fires:
|
|
45
|
+
if f.timestamp and f.timestamp.tzinfo:
|
|
46
|
+
return f.timestamp.tzinfo
|
|
47
|
+
return UTC
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def compute_vitals(
|
|
51
|
+
skills: list[Skill],
|
|
52
|
+
fires: list[Fire],
|
|
53
|
+
*,
|
|
54
|
+
window_days: int = 14,
|
|
55
|
+
now: datetime | None = None,
|
|
56
|
+
) -> list[SkillVitals]:
|
|
57
|
+
now = now or datetime.now(tz=_tz(fires))
|
|
58
|
+
window_start = now - timedelta(days=window_days)
|
|
59
|
+
|
|
60
|
+
by_name: dict[str, list[Fire]] = {}
|
|
61
|
+
for f in fires:
|
|
62
|
+
by_name.setdefault(f.name, []).append(f)
|
|
63
|
+
|
|
64
|
+
skill_by_name = {s.name: s for s in skills}
|
|
65
|
+
names = set(skill_by_name) | set(by_name)
|
|
66
|
+
|
|
67
|
+
out: list[SkillVitals] = []
|
|
68
|
+
for name in sorted(names):
|
|
69
|
+
skill = skill_by_name.get(name)
|
|
70
|
+
group = by_name.get(name, [])
|
|
71
|
+
|
|
72
|
+
invokes = [f for f in group if f.kind == FireKind.INVOKE]
|
|
73
|
+
attrs = [f for f in group if f.kind == FireKind.ATTRIBUTION]
|
|
74
|
+
win = [f for f in group if f.timestamp and f.timestamp >= window_start]
|
|
75
|
+
win_invokes = [f for f in win if f.kind == FireKind.INVOKE]
|
|
76
|
+
win_attrs = [f for f in win if f.kind == FireKind.ATTRIBUTION]
|
|
77
|
+
|
|
78
|
+
stamps = [f.timestamp for f in group if f.timestamp]
|
|
79
|
+
last_fired = max(stamps) if stamps else None
|
|
80
|
+
first_fired = min(stamps) if stamps else None
|
|
81
|
+
days_dormant = (now - last_fired).days if last_fired else None
|
|
82
|
+
|
|
83
|
+
invoke_count = len(invokes)
|
|
84
|
+
attribution_count = len(attrs)
|
|
85
|
+
engagement_ratio = attribution_count / max(invoke_count, 1)
|
|
86
|
+
|
|
87
|
+
context_tokens = skill.context_tokens if skill else 0
|
|
88
|
+
health = _classify(
|
|
89
|
+
has_skill=skill is not None,
|
|
90
|
+
total_activity=len(group),
|
|
91
|
+
window_activity=len(win),
|
|
92
|
+
invoke_count=invoke_count,
|
|
93
|
+
engagement_ratio=engagement_ratio,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
out.append(
|
|
97
|
+
SkillVitals(
|
|
98
|
+
name=name,
|
|
99
|
+
skill=skill,
|
|
100
|
+
invoke_count=invoke_count,
|
|
101
|
+
attribution_count=attribution_count,
|
|
102
|
+
window_invoke_count=len(win_invokes),
|
|
103
|
+
window_attribution_count=len(win_attrs),
|
|
104
|
+
last_fired=last_fired,
|
|
105
|
+
first_fired=first_fired,
|
|
106
|
+
days_dormant=days_dormant,
|
|
107
|
+
context_tokens=context_tokens,
|
|
108
|
+
tokens_per_fire=context_tokens,
|
|
109
|
+
engagement_ratio=engagement_ratio,
|
|
110
|
+
health=health,
|
|
111
|
+
sessions=len({f.session_id for f in group if f.session_id}),
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _is_dormant_for(v: SkillVitals, days: int, now: datetime) -> bool:
|
|
118
|
+
"""True if the skill has not activated within `days` (never-fired counts)."""
|
|
119
|
+
if v.health == Health.ORPHAN:
|
|
120
|
+
return False
|
|
121
|
+
if v.last_fired is None:
|
|
122
|
+
return True
|
|
123
|
+
return (now - v.last_fired).days >= days
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def find_dormant(
|
|
127
|
+
vitals: list[SkillVitals], *, days: int = 14, now: datetime | None = None
|
|
128
|
+
) -> list[SkillVitals]:
|
|
129
|
+
"""Skills inactive for >= `days`, sorted by context cost (most expensive first)."""
|
|
130
|
+
now = now or datetime.now(UTC)
|
|
131
|
+
dead = [v for v in vitals if _is_dormant_for(v, days, now)]
|
|
132
|
+
return sorted(dead, key=lambda v: v.context_tokens, reverse=True)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def dormant_token_cost(
|
|
136
|
+
vitals: list[SkillVitals], *, days: int = 14, now: datetime | None = None
|
|
137
|
+
) -> int:
|
|
138
|
+
"""Total context tokens loaded every session by dormant skills — the viral number."""
|
|
139
|
+
return sum(v.context_tokens for v in find_dormant(vitals, days=days, now=now))
|
skillvitals/cli.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""skillvitals command-line interface.
|
|
2
|
+
|
|
3
|
+
skillvitals scan # the headline: fires, ctx cost, health per skill
|
|
4
|
+
skillvitals report # markdown report (shareable / save with --output)
|
|
5
|
+
skillvitals history # per-skill activation history
|
|
6
|
+
skillvitals dormancy # dead-weight skills + token cost
|
|
7
|
+
skillvitals prescribe # suggested fixes (--rewrite for LLM rewrites)
|
|
8
|
+
skillvitals test # synthetic activation test (--live to really run)
|
|
9
|
+
skillvitals dashboard # write the self-contained HTML dashboard
|
|
10
|
+
skillvitals serve # run as an MCP server (stdio)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
from rich.table import Table
|
|
21
|
+
|
|
22
|
+
from . import __version__
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _now(value: str | None) -> datetime:
|
|
28
|
+
if value:
|
|
29
|
+
return datetime.fromisoformat(value).replace(tzinfo=UTC)
|
|
30
|
+
return datetime.now(UTC)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _apply_home(claude_home: str | None) -> None:
|
|
34
|
+
if claude_home:
|
|
35
|
+
os.environ["SKILLVITALS_CLAUDE_HOME"] = claude_home
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _collect(window_days: int, now: datetime, persist: bool = True):
|
|
39
|
+
from .pipeline import collect
|
|
40
|
+
|
|
41
|
+
return collect(window_days=window_days, now=now, persist=persist)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
now_opt = click.option("--now", default=None, help="Override 'now' (ISO date) — for testing.")
|
|
45
|
+
home_opt = click.option("--claude-home", default=None, help="Path to the .claude dir.")
|
|
46
|
+
days_opt = click.option("--days", default=14, show_default=True, help="Dormancy/activity window in days.")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@click.group(help="Skill observability for Claude Code.")
|
|
50
|
+
@click.version_option(__version__, prog_name="skillvitals")
|
|
51
|
+
def main() -> None:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@main.command()
|
|
56
|
+
@home_opt
|
|
57
|
+
@days_opt
|
|
58
|
+
@now_opt
|
|
59
|
+
def scan(claude_home, days, now):
|
|
60
|
+
"""Scan installed skills and report fires, context cost, and health."""
|
|
61
|
+
_apply_home(claude_home)
|
|
62
|
+
from .analysis import dormant_token_cost, find_dormant
|
|
63
|
+
from .hooks import detect_skill_hook
|
|
64
|
+
from .report import build_rich_table
|
|
65
|
+
from .tokens import humanize
|
|
66
|
+
|
|
67
|
+
n = _now(now)
|
|
68
|
+
snap = _collect(days, n)
|
|
69
|
+
console.print(build_rich_table(snap.vitals))
|
|
70
|
+
|
|
71
|
+
cost = dormant_token_cost(snap.vitals, days=days, now=n)
|
|
72
|
+
dead = find_dormant(snap.vitals, days=days, now=n)
|
|
73
|
+
if dead:
|
|
74
|
+
console.print(
|
|
75
|
+
f"\n[yellow]{len(dead)} dormant/never-fired skills are costing you "
|
|
76
|
+
f"[bold]{humanize(cost)}[/bold] tokens per session.[/yellow]"
|
|
77
|
+
)
|
|
78
|
+
console.print("Run [cyan]skillvitals prescribe[/cyan] for fixes.")
|
|
79
|
+
|
|
80
|
+
hook = detect_skill_hook(snap.config.home, snap.config.cwd)
|
|
81
|
+
status = "[green]detected[/green]" if hook["has_prompt_hook"] else "[dim]not detected[/dim]"
|
|
82
|
+
console.print(f"\nSkill-activation hook (UserPromptSubmit): {status}")
|
|
83
|
+
if snap.errors:
|
|
84
|
+
console.print(f"[dim]{len(snap.errors)} unparseable log line(s) skipped.[/dim]")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@main.command()
|
|
88
|
+
@home_opt
|
|
89
|
+
@days_opt
|
|
90
|
+
@now_opt
|
|
91
|
+
@click.option("--output", "-o", default=None, help="Write markdown to a file instead of stdout.")
|
|
92
|
+
def report(claude_home, days, now, output):
|
|
93
|
+
"""Render the full markdown report."""
|
|
94
|
+
_apply_home(claude_home)
|
|
95
|
+
from .report import render_markdown
|
|
96
|
+
|
|
97
|
+
n = _now(now)
|
|
98
|
+
snap = _collect(days, n)
|
|
99
|
+
md = render_markdown(snap.vitals, now=n, dormant_days=days)
|
|
100
|
+
if output:
|
|
101
|
+
from pathlib import Path
|
|
102
|
+
|
|
103
|
+
Path(output).write_text(md, encoding="utf-8")
|
|
104
|
+
console.print(f"[green]Wrote[/green] {output}")
|
|
105
|
+
else:
|
|
106
|
+
click.echo(md)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@main.command()
|
|
110
|
+
@home_opt
|
|
111
|
+
@days_opt
|
|
112
|
+
@now_opt
|
|
113
|
+
def history(claude_home, days, now):
|
|
114
|
+
"""Show per-skill activation history."""
|
|
115
|
+
_apply_home(claude_home)
|
|
116
|
+
from .report import humanize_age
|
|
117
|
+
|
|
118
|
+
n = _now(now)
|
|
119
|
+
snap = _collect(days, n)
|
|
120
|
+
table = Table(title=f"skill activation history (last {days}d window)", header_style="bold cyan")
|
|
121
|
+
for col in ("skill", "invokes", "engaged", "sessions", "last seen", "first seen"):
|
|
122
|
+
table.add_column(col, justify="right" if col in {"invokes", "engaged", "sessions"} else "left")
|
|
123
|
+
for v in sorted(snap.vitals, key=lambda v: (-v.attribution_count, -v.invoke_count)):
|
|
124
|
+
first = v.first_fired.date().isoformat() if v.first_fired else "—"
|
|
125
|
+
table.add_row(v.name, str(v.invoke_count), str(v.attribution_count),
|
|
126
|
+
str(v.sessions), humanize_age(v.days_dormant), first)
|
|
127
|
+
console.print(table)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@main.command()
|
|
131
|
+
@home_opt
|
|
132
|
+
@days_opt
|
|
133
|
+
@now_opt
|
|
134
|
+
def dormancy(claude_home, days, now):
|
|
135
|
+
"""List skills inactive for N days and their context cost."""
|
|
136
|
+
_apply_home(claude_home)
|
|
137
|
+
from .analysis import dormant_token_cost, find_dormant
|
|
138
|
+
from .tokens import humanize
|
|
139
|
+
|
|
140
|
+
n = _now(now)
|
|
141
|
+
snap = _collect(days, n)
|
|
142
|
+
dead = find_dormant(snap.vitals, days=days, now=n)
|
|
143
|
+
table = Table(title=f"dormant skills (inactive ≥ {days}d)", header_style="bold yellow")
|
|
144
|
+
table.add_column("skill")
|
|
145
|
+
table.add_column("ctx tokens", justify="right")
|
|
146
|
+
table.add_column("last seen")
|
|
147
|
+
for v in dead:
|
|
148
|
+
last = "never" if v.days_dormant is None else f"{v.days_dormant}d ago"
|
|
149
|
+
table.add_row(v.name, humanize(v.context_tokens), last)
|
|
150
|
+
console.print(table)
|
|
151
|
+
console.print(
|
|
152
|
+
f"\n[bold yellow]{humanize(dormant_token_cost(snap.vitals, days=days, now=n))}[/bold yellow]"
|
|
153
|
+
" tokens of dead weight loaded every session."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _make_client():
|
|
158
|
+
"""Build an anthropic client if the extra is installed and a key is set."""
|
|
159
|
+
if not os.environ.get("ANTHROPIC_API_KEY"):
|
|
160
|
+
return None
|
|
161
|
+
try:
|
|
162
|
+
import anthropic
|
|
163
|
+
|
|
164
|
+
return anthropic.Anthropic()
|
|
165
|
+
except ImportError:
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@main.command()
|
|
170
|
+
@home_opt
|
|
171
|
+
@days_opt
|
|
172
|
+
@now_opt
|
|
173
|
+
@click.option("--rewrite", is_flag=True, help="Use an LLM to rewrite weak descriptions (opt-in, uses your key).")
|
|
174
|
+
def prescribe(claude_home, days, now, rewrite):
|
|
175
|
+
"""Suggest fixes for dormant / low-activation / redundant skills."""
|
|
176
|
+
_apply_home(claude_home)
|
|
177
|
+
from .prescribe import prescribe as run_prescribe
|
|
178
|
+
from .prescribe import rewrite_description
|
|
179
|
+
|
|
180
|
+
n = _now(now)
|
|
181
|
+
snap = _collect(days, n)
|
|
182
|
+
rx = run_prescribe(snap.vitals, now=n)
|
|
183
|
+
if not rx:
|
|
184
|
+
console.print("[green]No prescriptions — your skills look healthy.[/green]")
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
sev_color = {"critical": "red", "warn": "yellow", "info": "dim"}
|
|
188
|
+
by_skill: dict[str, list] = {}
|
|
189
|
+
for p in rx:
|
|
190
|
+
by_skill.setdefault(p.skill_name, []).append(p)
|
|
191
|
+
for skill_name, items in by_skill.items():
|
|
192
|
+
console.print(f"\n[bold]{skill_name}[/bold]")
|
|
193
|
+
for p in items:
|
|
194
|
+
c = sev_color.get(p.severity.value, "white")
|
|
195
|
+
console.print(f" [{c}]●[/{c}] [{c}]{p.rule}[/{c}]: {p.message}")
|
|
196
|
+
|
|
197
|
+
if rewrite:
|
|
198
|
+
client = _make_client()
|
|
199
|
+
if client is None:
|
|
200
|
+
console.print("\n[yellow]--rewrite needs the 'llm' extra and ANTHROPIC_API_KEY.[/yellow]")
|
|
201
|
+
return
|
|
202
|
+
console.print("\n[bold]Suggested description rewrites:[/bold]")
|
|
203
|
+
skills = {s.name: s for s in snap.skills}
|
|
204
|
+
targets = {p.skill_name for p in rx
|
|
205
|
+
if p.rule in {"description-too-short", "no-trigger-words", "low-quality"}}
|
|
206
|
+
for name in targets:
|
|
207
|
+
if name in skills:
|
|
208
|
+
new = rewrite_description(skills[name], client=client)
|
|
209
|
+
if new:
|
|
210
|
+
console.print(f"\n[bold]{name}[/bold]:\n {new}")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@main.command(name="test")
|
|
214
|
+
@home_opt
|
|
215
|
+
@click.option("--skill", required=True, help="Skill name to test.")
|
|
216
|
+
@click.option("--n", default=10, show_default=True, help="Number of synthetic prompts.")
|
|
217
|
+
@click.option("--live", is_flag=True, help="Actually run prompts through headless Claude Code.")
|
|
218
|
+
def test_cmd(claude_home, skill, n, live):
|
|
219
|
+
"""Generate (and optionally run) an activation test battery for a skill."""
|
|
220
|
+
_apply_home(claude_home)
|
|
221
|
+
from .config import load_config
|
|
222
|
+
from .registry import scan_skills
|
|
223
|
+
from .testharness import make_cli_runner, measure_activation, synth_prompts
|
|
224
|
+
|
|
225
|
+
config = load_config()
|
|
226
|
+
skills = {s.name: s for s in scan_skills(config.skill_roots())}
|
|
227
|
+
if skill not in skills:
|
|
228
|
+
raise click.ClickException(f"Skill '{skill}' not found. Run `skillvitals scan` to list skills.")
|
|
229
|
+
|
|
230
|
+
prompts = synth_prompts(skills[skill].description, n=n)
|
|
231
|
+
console.print(f"[bold]{skill}[/bold] — {len(prompts)} synthetic test prompts:")
|
|
232
|
+
for i, p in enumerate(prompts, 1):
|
|
233
|
+
console.print(f" {i}. {p}")
|
|
234
|
+
|
|
235
|
+
if not live:
|
|
236
|
+
console.print("\n[dim]Dry run. Re-run with --live to measure real activation "
|
|
237
|
+
"(spawns headless Claude Code, uses your plan).[/dim]")
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
console.print("\n[cyan]Running live activation test…[/cyan]")
|
|
241
|
+
runner = make_cli_runner(skill, claude_home=config.home, cwd=config.cwd)
|
|
242
|
+
res = measure_activation(skill, prompts, runner=runner)
|
|
243
|
+
color = {"green": "green", "yellow": "yellow", "red": "red"}[res.grade]
|
|
244
|
+
console.print(f"Activation rate: [{color}]{res.activation_rate:.0%}[/{color}] "
|
|
245
|
+
f"({res.activations}/{res.prompts_run}) — [{color}]{res.grade}[/{color}]")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@main.command()
|
|
249
|
+
@home_opt
|
|
250
|
+
@days_opt
|
|
251
|
+
@now_opt
|
|
252
|
+
@click.option("--output", "-o", default=None, help="Where to write the HTML (default ~/.skillvitals/dashboard.html).")
|
|
253
|
+
@click.option("--open", "open_", is_flag=True, help="Open the dashboard in your browser.")
|
|
254
|
+
def dashboard(claude_home, days, now, output, open_):
|
|
255
|
+
"""Generate the self-contained HTML dashboard."""
|
|
256
|
+
_apply_home(claude_home)
|
|
257
|
+
from .dashboard import write_dashboard
|
|
258
|
+
from .prescribe import prescribe as run_prescribe
|
|
259
|
+
|
|
260
|
+
n = _now(now)
|
|
261
|
+
snap = _collect(days, n)
|
|
262
|
+
out = output or str(snap.config.dashboard_path)
|
|
263
|
+
rx = run_prescribe(snap.vitals, now=n)
|
|
264
|
+
path = write_dashboard(snap.vitals, out, now=n, dormant_days=days, prescriptions=rx)
|
|
265
|
+
console.print(f"[green]Dashboard written to[/green] {path}")
|
|
266
|
+
if open_:
|
|
267
|
+
import webbrowser
|
|
268
|
+
|
|
269
|
+
webbrowser.open(f"file://{path}")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@main.command()
|
|
273
|
+
@home_opt
|
|
274
|
+
def serve(claude_home):
|
|
275
|
+
"""Run skillvitals as an MCP server (stdio transport)."""
|
|
276
|
+
_apply_home(claude_home)
|
|
277
|
+
from .server import mcp
|
|
278
|
+
|
|
279
|
+
mcp.run()
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
if __name__ == "__main__":
|
|
283
|
+
main()
|
skillvitals/config.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Path resolution and configuration.
|
|
2
|
+
|
|
3
|
+
Everything reads from the user's Claude Code data under ``~/.claude``. All
|
|
4
|
+
locations are overridable via environment variables so tests (and curious
|
|
5
|
+
users) can point skillvitals at a fixture tree:
|
|
6
|
+
|
|
7
|
+
SKILLVITALS_CLAUDE_HOME -> the .claude dir (default ~/.claude)
|
|
8
|
+
CLAUDE_CONFIG_DIR -> respected as a fallback (Claude Code's own var)
|
|
9
|
+
SKILLVITALS_HOME -> where the db + dashboard live (default ~/.skillvitals)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .registry import ScanRoot
|
|
19
|
+
|
|
20
|
+
DORMANT_DAYS_DEFAULT = 14
|
|
21
|
+
HISTORY_DAYS_DEFAULT = 30
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def claude_home() -> Path:
|
|
25
|
+
env = os.environ.get("SKILLVITALS_CLAUDE_HOME") or os.environ.get("CLAUDE_CONFIG_DIR")
|
|
26
|
+
if env:
|
|
27
|
+
return Path(env).expanduser()
|
|
28
|
+
return Path.home() / ".claude"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def skillvitals_home() -> Path:
|
|
32
|
+
env = os.environ.get("SKILLVITALS_HOME")
|
|
33
|
+
base = Path(env).expanduser() if env else Path.home() / ".skillvitals"
|
|
34
|
+
return base
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Config:
|
|
39
|
+
home: Path # the .claude dir
|
|
40
|
+
sv_home: Path # ~/.skillvitals
|
|
41
|
+
cwd: Path
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def projects_dir(self) -> Path:
|
|
45
|
+
return self.home / "projects"
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def db_path(self) -> Path:
|
|
49
|
+
return self.sv_home / "db.sqlite"
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def dashboard_path(self) -> Path:
|
|
53
|
+
return self.sv_home / "dashboard.html"
|
|
54
|
+
|
|
55
|
+
def skill_roots(self) -> list[ScanRoot]:
|
|
56
|
+
"""Roots to scan, in precedence order. Plugin-cache files self-tag as
|
|
57
|
+
'plugin' regardless of label (see registry._classify_path)."""
|
|
58
|
+
roots = [
|
|
59
|
+
ScanRoot(self.home / "skills", "user"),
|
|
60
|
+
ScanRoot(self.home / "plugins" / "cache", "plugin"),
|
|
61
|
+
]
|
|
62
|
+
project_skills = self.cwd / ".claude" / "skills"
|
|
63
|
+
if project_skills.exists():
|
|
64
|
+
roots.insert(0, ScanRoot(project_skills, "project"))
|
|
65
|
+
return roots
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load_config(cwd: Path | None = None) -> Config:
|
|
69
|
+
return Config(
|
|
70
|
+
home=claude_home(),
|
|
71
|
+
sv_home=skillvitals_home(),
|
|
72
|
+
cwd=Path(cwd) if cwd else Path.cwd(),
|
|
73
|
+
)
|
skillvitals/dashboard.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Self-contained HTML dashboard (single file, inline CSS + vanilla JS sort).
|
|
2
|
+
|
|
3
|
+
No build step, no framework, no external resources — the file works offline and
|
|
4
|
+
can be opened straight from disk. Jinja2-rendered from the bundled template.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
|
|
11
|
+
from jinja2 import Environment, PackageLoader, select_autoescape
|
|
12
|
+
|
|
13
|
+
from .analysis import dormant_token_cost, find_dormant
|
|
14
|
+
from .models import Health, Prescription, SkillVitals
|
|
15
|
+
from .prescribe import prescribe
|
|
16
|
+
from .report import humanize_age
|
|
17
|
+
from .tokens import humanize
|
|
18
|
+
|
|
19
|
+
_STATUS_LABEL = {
|
|
20
|
+
Health.HEALTHY: "healthy", Health.DORMANT: "dormant", Health.MISFIRING: "misfiring",
|
|
21
|
+
Health.NEVER_FIRED: "never-fired", Health.ORPHAN: "orphan",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_env = Environment(
|
|
25
|
+
loader=PackageLoader("skillvitals", "templates"),
|
|
26
|
+
autoescape=select_autoescape(["html", "j2"]),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _rows(vitals: list[SkillVitals]) -> list[dict]:
|
|
31
|
+
order = {Health.HEALTHY: 0, Health.MISFIRING: 1, Health.DORMANT: 2,
|
|
32
|
+
Health.NEVER_FIRED: 3, Health.ORPHAN: 4}
|
|
33
|
+
rows = []
|
|
34
|
+
for v in sorted(vitals, key=lambda v: (order[v.health], -v.attribution_count, -v.context_tokens)):
|
|
35
|
+
rows.append({
|
|
36
|
+
"name": v.name,
|
|
37
|
+
"fires": v.invoke_count,
|
|
38
|
+
"engaged": v.attribution_count,
|
|
39
|
+
"ctx": v.context_tokens,
|
|
40
|
+
"ctx_h": humanize(v.context_tokens),
|
|
41
|
+
"quality": v.skill.quality_score if v.skill else 0,
|
|
42
|
+
"last_seen": humanize_age(v.days_dormant),
|
|
43
|
+
"age_v": -1 if v.days_dormant is None else v.days_dormant,
|
|
44
|
+
"status_label": _STATUS_LABEL[v.health],
|
|
45
|
+
"status_class": v.health.value,
|
|
46
|
+
})
|
|
47
|
+
return rows
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def render_dashboard(
|
|
51
|
+
vitals: list[SkillVitals],
|
|
52
|
+
*,
|
|
53
|
+
now: datetime | None = None,
|
|
54
|
+
dormant_days: int = 14,
|
|
55
|
+
prescriptions: list[Prescription] | None = None,
|
|
56
|
+
generated_at: str | None = None,
|
|
57
|
+
) -> str:
|
|
58
|
+
now = now or datetime.now(UTC)
|
|
59
|
+
if prescriptions is None:
|
|
60
|
+
prescriptions = prescribe(vitals, now=now)
|
|
61
|
+
dead = find_dormant(vitals, days=dormant_days, now=now)
|
|
62
|
+
template = _env.get_template("dashboard.html.j2")
|
|
63
|
+
return template.render(
|
|
64
|
+
rows=_rows(vitals),
|
|
65
|
+
prescriptions=[
|
|
66
|
+
{"skill_name": p.skill_name, "severity": p.severity.value, "message": p.message}
|
|
67
|
+
for p in prescriptions
|
|
68
|
+
],
|
|
69
|
+
total=len(vitals),
|
|
70
|
+
healthy=sum(1 for v in vitals if v.health == Health.HEALTHY),
|
|
71
|
+
dormant_count=len(dead),
|
|
72
|
+
dormant_cost_h=humanize(dormant_token_cost(vitals, days=dormant_days, now=now)),
|
|
73
|
+
generated_at=generated_at or now.strftime("%Y-%m-%d %H:%M UTC"),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def write_dashboard(vitals: list[SkillVitals], path, **kwargs) -> str:
|
|
78
|
+
from pathlib import Path
|
|
79
|
+
|
|
80
|
+
path = Path(path)
|
|
81
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
html = render_dashboard(vitals, **kwargs)
|
|
83
|
+
path.write_text(html, encoding="utf-8")
|
|
84
|
+
return str(path)
|
skillvitals/hooks.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Lightweight hook-coverage detection.
|
|
2
|
+
|
|
3
|
+
The ecosystem's answer to low skill activation is a UserPromptSubmit hook that
|
|
4
|
+
forces skill evaluation (skills-hook, claude-skills-supercharged, etc.).
|
|
5
|
+
skillvitals doesn't generate hooks (explicit non-goal) — it just reports whether
|
|
6
|
+
one is configured, so a dormant skill's diagnosis can say "no activation hook is
|
|
7
|
+
helping it fire."
|
|
8
|
+
|
|
9
|
+
We read ``settings.json`` / ``settings.local.json`` from the .claude dir and the
|
|
10
|
+
project dir, and look for a UserPromptSubmit hook. This is a global signal, not
|
|
11
|
+
per-skill — we deliberately don't claim more precision than we can defend.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load_settings(path: Path) -> dict:
|
|
21
|
+
try:
|
|
22
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
23
|
+
except (OSError, json.JSONDecodeError):
|
|
24
|
+
return {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def detect_skill_hook(claude_home: Path, cwd: Path | None = None) -> dict:
|
|
28
|
+
"""Return {'has_prompt_hook': bool, 'sources': [paths], 'mentions_skill': bool}."""
|
|
29
|
+
candidates = [
|
|
30
|
+
claude_home / "settings.json",
|
|
31
|
+
claude_home / "settings.local.json",
|
|
32
|
+
]
|
|
33
|
+
if cwd:
|
|
34
|
+
candidates += [cwd / ".claude" / "settings.json", cwd / ".claude" / "settings.local.json"]
|
|
35
|
+
|
|
36
|
+
has_hook = False
|
|
37
|
+
mentions_skill = False
|
|
38
|
+
sources: list[str] = []
|
|
39
|
+
for path in candidates:
|
|
40
|
+
if not path.exists():
|
|
41
|
+
continue
|
|
42
|
+
data = _load_settings(path)
|
|
43
|
+
hooks = data.get("hooks") or {}
|
|
44
|
+
if "UserPromptSubmit" in hooks and hooks["UserPromptSubmit"]:
|
|
45
|
+
has_hook = True
|
|
46
|
+
sources.append(str(path))
|
|
47
|
+
if "skill" in json.dumps(hooks["UserPromptSubmit"]).lower():
|
|
48
|
+
mentions_skill = True
|
|
49
|
+
return {"has_prompt_hook": has_hook, "sources": sources, "mentions_skill": mentions_skill}
|