Reports at session start when plugins declared in settings are not actually installed or are disabled for this project

A highly resilient, declarative dotfiles setup using Chezmoi, Mise, and Python, optimized for Linux-based devcontainers.
mise install # Install all tools
mise run lint # Run lint checks (read-only ≡ CI; `mise run fmt` auto-fixes)
uv run --project python pytest tests/ -x -q # Run all 190 tests
docker buildx bake dev-load # Build devcontainer locally
curl -fsSL https://raw.githubusercontent.com/sortakool/dotfiles/main/install.sh | bash
install.sh bootstraps mise.mise installs git, chezmoi, and uv.chezmoi init clones the repo and applies templated configs.uv run) handle complex orchestration and tool installations.DotfilesConfig centralizes 16 env vars; hk.pkl for git hooks with shared hk-common.pkl checks.dotfiles_setup).noqa, type: ignore, or pylint: disable — enforced by no_lint_skip hk step.changes path-gate → base-prep → p2996-prep → build → smoke-test (smoke + Dive); promote retags on main; benchmark + Trivy run async in image-analysis.yml. Docs-only changes skip the build chain.All tools are declared in mise.toml and installed via mise install. Python dependencies are managed via uv with python/pyproject.toml.
uv run --project python pytest tests/ -x -q # All tests
uv run --project python dotfiles-setup verify run # Contract verification
mise run pin-actions # Verify GHA SHA-pinning
mise run lint-docs # Validate agent documentationhooks/plugin-health.ts 95 lines1import type { PluginHealthCode } from "../../../types/plugin-health";
2import type { Register } from "claude-code";
3
4/**
5 * SessionStart hook for plugin/skill health.
6 *
7 * Reconciles plugins declared in `.claude/settings.json` (enabledPlugins)
8 * against what's actually installed and enabled (`claude plugin list --json`).
9 * Reports drift only — OK result has no additionalContext.
10 */
11
12type PluginHealthReport = {
13 code: number; // PluginHealthCode member
14 declared_not_effective?: string[];
15 effective_not_declared?: string[];
16 cli_failed_diagnostic?: string | null;
17};
18
19/**
20 * Runs the plugin health check with the ambient PATH.
21 */
22async function readPluginHealth($: {
23 env: { get: (name: string) => Promise<string | undefined> };
24 process: {
25 run: (
26 argv: readonly string[],
27 init?: {
28 cwd?: string;
29 env?: Record<string, string>;
30 timeoutMs?: number;
31 },
32 ) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
33 };
34}): Promise<PluginHealthReport | null> {
35 const ambientPath = await $.env.get("PATH");
36 const projectDir = await $.env.get("CLAUDE_PROJECT_DIR");
37 try {
38 const { stdout } = await $.process.run(
39 ["uv", "run", "--project", "python", "dotfiles-setup", "plugin-health"],
40 {
41 cwd: projectDir,
42 env: ambientPath ? { DOTFILES_AMBIENT_PATH: ambientPath } : {},
43 timeoutMs: 30_000,
44 },
45 );
46 // The rc is authoritative (unlike install-doctor), so we read it.
47 // But the JSON carries the detail either way.
48 return JSON.parse(stdout) as PluginHealthReport;
49 } catch {
50 // A hook that throws fails open and silent.
51 return null;
52 }
53}
54
55export const register: Register = (on) => {
56 on("classic.SessionStart", async ($, e, next) => {
57 const result = await next(e);
58 const health = await readPluginHealth($);
59
60 if (health === null) {
61 // Could not run the check — fail open, no context.
62 return result;
63 }
64
65 if (health.code === 0) {
66 // OK — no drift.
67 return result;
68 }
69
70 // Any other code = drift or error. Report it.
71 const lines: string[] = [];
72
73 if (health.declared_not_effective && health.declared_not_effective.length > 0) {
74 lines.push(
75 `plugin-health: declared but NOT effective: ${health.declared_not_effective.join(", ")}`,
76 );
77 }
78
79 if (health.effective_not_declared && health.effective_not_declared.length > 0) {
80 lines.push(
81 `plugin-health: effective but NOT declared: ${health.effective_not_declared.join(", ")}`,
82 );
83 }
84
85 if (health.cli_failed_diagnostic) {
86 lines.push(`plugin-health: claude plugin list failed: ${health.cli_failed_diagnostic}`);
87 }
88
89 return {
90 ...result,
91 additionalContext: lines.length > 0 ? lines : result.additionalContext,
92 };
93 });
94};
95