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.
@@ -0,0 +1,3 @@
1
+ """skillvitals — skill observability for Claude Code."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from skillvitals.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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
+ )
@@ -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}