SLOPSHOPPER

plugin-health

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

newprocess
★ 4v1.0.0no licenseupdated 2026-10-02ray-manaloto/dotfiles/.claude/skills/plugin-health
A shopper browsing a rack in a slop shop
README

Reproducible Dotfiles (AMD64)

A highly resilient, declarative dotfiles setup using Chezmoi, Mise, and Python, optimized for Linux-based devcontainers.

Quick Start

Local Development

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 Build

docker buildx bake dev-load                        # Build devcontainer locally

Bootstrap (Container)

curl -fsSL https://raw.githubusercontent.com/sortakool/dotfiles/main/install.sh | bash

Architecture

  1. Stage 0: install.sh bootstraps mise.
  2. Stage 1: mise installs git, chezmoi, and uv.
  3. Stage 2: chezmoi init clones the repo and applies templated configs.
  4. Stage 3: Python lifecycle hooks (uv run) handle complex orchestration and tool installations.

Features

  • Strictly AMD64: Forced x86_64 architecture for container consistency.
  • Declarative Config: Pydantic DotfilesConfig centralizes 16 env vars; hk.pkl for git hooks with shared hk-common.pkl checks.
  • Zero-Bash: Logic is encapsulated in a typed, linted Python library (dotfiles_setup).
  • Zero Lint Suppressions: No noqa, type: ignore, or pylint: disable — enforced by no_lint_skip hk step.
  • Environment Auditor: Built-in health checks for identity, toolchains, and SSH connectivity.
  • CI/CD: GitHub Actions — lint → contract-preflight → a 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.

Tool Management

All tools are declared in mise.toml and installed via mise install. Python dependencies are managed via uv with python/pyproject.toml.

Local Testing

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 documentation
Source 1 files
hooks/plugin-health.ts 95 lines
1import 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