SLOPSHOPPER

install-doctor

Reports the install-doctor verdict at session start and refuses tool calls while the install is provably broken

newprocess
★ 4v1.0.0no licenseupdated 2026-10-01ray-manaloto/dotfiles/.agents/skills/install-doctor
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/register.ts 430 lines
1import type { Register } from "claude-code";
2
3/**
4 * The repo's first production function hook.
5 *
6 * Two handlers, and the split is load-bearing rather than stylistic. Grilling
7 * decision Q13 put the REPORT on `classic.SessionStart` and the DENIAL on
8 * `classic.PreToolUse` because SessionStart structurally cannot block: the
9 * harness documents it as "Context only - No blocking or decision control",
10 * and universal result fields are accepted by every event and discarded by
11 * some.
12 *
13 * That is invisible to the build gates. `ClassicResultOf` in
14 * `.claude/types/claude-code.d.ts` types every non-PreToolUse classic event
15 * with `preventContinuation` and `block`, so a SessionStart handler that tried
16 * to block would compile cleanly, pass `claude plugin validate`, pass `tsc`,
17 * and then silently do nothing at runtime. Function-hook failures fail open and
18 * silent. Verify this module by observed behaviour, never by a green build.
19 *
20 * The verdict is computed at session start and cached in module scope for
21 * PreToolUse to read. Only a call that would otherwise be denied re-establishes
22 * it, with a short reuse interval so a repair loop does not spawn
23 * `claude doctor` plus a release-list lookup on every attempt.
24 */
25
26/** Mirrors `DoctorVerdict.to_json()` in `python/src/dotfiles_setup/install_doctor.py`. */
27type DoctorReport = {
28  verdict: "ok" | "invalid" | "unknown" | "drift";
29  enforcement_eligible: boolean;
30  enforcement_eligibility_malformed: boolean;
31  disabled_by_baseline: boolean;
32  baseline_path?: string;
33  findings: string[];
34};
35
36/**
37 * The last verdict, or `null` before SessionStart has run.
38 *
39 * `null` means "not yet established", which is NOT the same as OK - but it is
40 * treated permissively for the same reason `Verdict.UNKNOWN` is: a question
41 * that was never asked must never block a tool call.
42 */
43let cachedReport: DoctorReport | null = null;
44
45/** Reuse a deny-path refresh briefly; the injected clock keeps tests sleepless. */
46const REFRESH_REUSE_MS = 7_500;
47let lastRefreshAtMs: number | null = null;
48
49type HookServices = {
50  clock: { now: () => Promise<number> };
51  env: { get: (name: string) => Promise<string | undefined> };
52  fs: {
53    stat: (
54      path: string,
55      options?: { resolve: boolean },
56    ) => Promise<{ isLink: boolean; realPath?: string }>;
57  };
58  process: {
59    run: (
60      argv: readonly string[],
61      init?: { cwd?: string; env?: Record<string, string>; timeoutMs?: number },
62    ) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
63  };
64  session: { root: () => Promise<string> };
65};
66
67/**
68 * Runs the Python check with the ambient PATH captured from this process.
69 *
70 * The capture is the whole reason this is not a one-liner. `uv run` executes
71 * under mise's activated environment, which prepends mise install dirs to PATH
72 * before Python starts - so a check that reads its own inherited PATH resolves
73 * mise's pinned `claude` rather than the native install the operator actually
74 * runs. (That pin was `npm:` until #1043 and produced a broken launcher; it is
75 * `github:` now, which is a real binary - but still not the operator's
76 * install, so the capture matters either way.) Handing down `DOTFILES_AMBIENT_PATH`
77 * (this process's PATH, which is the launching shell's) is what makes the
78 * check measure the right binary; without it `resolve_ambient_path` falls back
79 * to the rewritten one.
80 */
81/** Validate untrusted subprocess JSON before it can enter the session cache. */
82function parseDoctorReport(value: unknown): DoctorReport | null {
83  const record = value as Record<string, unknown>;
84  const verdict = record.verdict;
85  if (
86    verdict !== "ok" &&
87    verdict !== "invalid" &&
88    verdict !== "unknown" &&
89    verdict !== "drift"
90  ) {
91    return null;
92  }
93  if (
94    !Array.isArray(record.findings) ||
95    !record.findings.every((finding) => typeof finding === "string")
96  ) {
97    return null;
98  }
99  if (record.baseline_path !== undefined && typeof record.baseline_path !== "string") {
100    return null;
101  }
102  if (
103    record.disabled_by_baseline !== undefined &&
104    typeof record.disabled_by_baseline !== "boolean"
105  ) {
106    return null;
107  }
108  const enforcementEligibilityMalformed =
109    record.enforcement_eligible !== undefined &&
110    typeof record.enforcement_eligible !== "boolean";
111  return {
112    verdict,
113    enforcement_eligible: record.enforcement_eligible === true,
114    enforcement_eligibility_malformed: enforcementEligibilityMalformed,
115    disabled_by_baseline: record.disabled_by_baseline === true,
116    ...(record.baseline_path === undefined ? {} : { baseline_path: record.baseline_path }),
117    findings: record.findings,
118  };
119}
120
121async function readVerdict($: HookServices): Promise<DoctorReport | null> {
122  try {
123    const ambientPath = await $.env.get("PATH");
124    const projectDir = await $.env.get("CLAUDE_PROJECT_DIR");
125    const { stdout } = await $.process.run(
126      ["uv", "run", "--project", "python", "dotfiles-setup", "install-doctor"],
127      {
128        cwd: projectDir,
129        env: ambientPath ? { DOTFILES_AMBIENT_PATH: ambientPath } : {},
130        timeoutMs: 90_000,
131      },
132    );
133    // rc is deliberately ignored: it encodes enforcement eligibility, not
134    // success, and the JSON carries the verdict either way.
135    return parseDoctorReport(JSON.parse(stdout) as unknown);
136  } catch {
137    // A hook that throws fails open and silent, so failure must be a value.
138    // Leaving the cache null means PreToolUse denies nothing, which is correct:
139    // nothing was established.
140    return null;
141  }
142}
143
144/** Tools that only READ. Denying these is how a gate becomes unrecoverable. */
145const READ_ONLY_TOOLS = new Set(["Read", "Glob", "Grep", "NotebookRead", "TodoWrite"]);
146
147/**
148 * Tools that cannot repair anything, and must never be denied anyway.
149 *
150 * `READ_ONLY_TOOLS` is scoped by what a tool DOES; this set is scoped by what
151 * denying it COSTS. The split is deliberate rather than tidy: appending these
152 * to a constant documented as "tools that only READ" would make that name lie,
153 * and the next reader applying the name literally would remove them as a
154 * mistake. Two sets, two reasons, both feeding one permit decision.
155 *
156 * Measured 2026-09-13, three sessions in a row. With the verdict INVALID this
157 * hook denied `AskUserQuestion` and `SendUserMessage` — the only tools that can
158 * put the repair choice to the operator, or report the finding at all. The
159 * session could see the problem and had no way to say so; the fix had to be
160 * dictated as plain text and applied by hand. That is precisely the failure
161 * this module's own contract names above: a gate you cannot talk your way out
162 * of does not protect the session, it ends it.
163 *
164 * Neither tool can run a command, so permitting them widens nothing. The gate's
165 * whole purpose is preventing silent wrong-version EXECUTION, and asking a
166 * question executes nothing.
167 */
168const ESCAPE_HATCH_TOOLS = new Set(["AskUserQuestion", "SendUserMessage"]);
169
170/** Programs that can repair the install. Matched on a token's basename. */
171const REPAIR_PROGRAMS = new Set(["claude", "mise", "uv"]);
172
173/**
174 * Where the native installer keeps its versioned binaries.
175 *
176 * Needed because the repair actually performed on 2026-09-13 was
177 * `~/.local/share/claude/versions/2.1.270 install latest` - run by absolute
178 * path precisely BECAUSE the launcher symlink was the missing thing. Its
179 * basename is a version number, so a basename-only rule refuses the one
180 * command that fixes the one failure this gate exists to report.
181 */
182const NATIVE_VERSIONS_DIR = "/claude/versions/";
183
184/** Shell metacharacters that separate one command from the next. */
185const COMMAND_SEPARATORS = /[\s;|&()<>]+/;
186
187type Placement = { realPath: string; isLink: boolean };
188
189/** Resolve an existing path, or place a missing basename through its parent. */
190async function placed($: HookServices, path: string): Promise<Placement | undefined> {
191  const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
192  const name = path.slice(cut + 1);
193  const isPlaceable =
194    !/^[A-Za-z]:(?![\\/])/.test(path) &&
195    !/^[\\/][\\/]/.test(path) &&
196    !/^[A-Za-z]:/.test(name) &&
197    name !== "" &&
198    name !== "." &&
199    name !== "..";
200  if (!isPlaceable) return undefined;
201
202  const own = await $.fs.stat(path, { resolve: true }).catch(() => undefined);
203  if (own !== undefined) {
204    return own.realPath === undefined
205      ? undefined
206      : { realPath: own.realPath, isLink: own.isLink };
207  }
208
209  const folder = cut < 0 ? "." : path.slice(0, cut + 1);
210  const dir = await $.fs.stat(folder, { resolve: true }).catch(() => undefined);
211  if (dir?.realPath === undefined) return undefined;
212  return {
213    realPath: `${dir.realPath.replace(/[\\/]$/, "")}/${name}`,
214    isLink: false,
215  };
216}
217
218/** True only for Edit/Write of the baseline doctor.toml Python actually read. */
219async function isDoctorTomlRepair(
220  $: HookServices,
221  e: { tool: string } & Record<string, unknown>,
222): Promise<boolean> {
223  if ((e.tool !== "Edit" && e.tool !== "Write") || typeof e.file_path !== "string") {
224    return false;
225  }
226  try {
227    let expectedPath = cachedReport?.baseline_path;
228    if (expectedPath === undefined) {
229      let root: string | undefined;
230      try {
231        root = await $.session.root();
232      } catch {
233        root = await $.env.get("CLAUDE_PROJECT_DIR");
234      }
235      if (!root) return false;
236      expectedPath = `${root.replace(/[\\/]$/, "")}/doctor.toml`;
237    }
238    const [target, expected] = await Promise.all([
239      placed($, e.file_path),
240      placed($, expectedPath),
241    ]);
242    return (
243      target !== undefined &&
244      expected !== undefined &&
245      !target.isLink &&
246      !expected.isLink &&
247      target.realPath === expected.realPath
248    );
249  } catch {
250    return false;
251  }
252}
253
254/** INVALID cannot be talked down; an explicit true may make other states enforce. */
255function isEnforcementEligible(report: DoctorReport | null): boolean {
256  if (report === null) return false;
257  return report.verdict === "invalid" || report.enforcement_eligible;
258}
259
260/** Accept an enforcing refresh or a positive answer that may clear the deny. */
261function isEstablishedRefresh(report: DoctorReport): boolean {
262  if (report.enforcement_eligibility_malformed) {
263    return report.verdict === "invalid";
264  }
265  return (
266    isEnforcementEligible(report) ||
267    report.verdict === "ok" ||
268    report.verdict === "drift" ||
269    report.disabled_by_baseline
270  );
271}
272
273async function refreshCachedReport($: HookServices): Promise<void> {
274  const refreshed = await readVerdict($);
275  if (refreshed !== null && isEstablishedRefresh(refreshed)) {
276    cachedReport = refreshed;
277  }
278}
279
280/**
281 * May this tool call proceed while the install is provably broken?
282 *
283 * Grilling decision Q18 is "deny everything except named repair commands", and
284 * the constraint that shapes it is recoverability: **whatever this refuses
285 * cannot be used to fix the thing being complained about.** A gate you cannot
286 * talk your way out of does not protect the session, it ends it.
287 *
288 * So the rule is deliberately usability-first:
289 *
290 * - read-only tools always pass, because the first thing anyone needs is to
291 *   SEE the finding, this file, and the settings that disable the plugin;
292 * - escape-hatch tools always pass, because the second thing anyone needs is to
293 *   ASK about it or REPORT it, and neither executes anything;
294 * - Edit/Write pass only when resolved placement identifies the repository-root
295 *   `doctor.toml`, so the reviewed off-switch can be changed in-session;
296 * - a Bash command passes when ANY of its tokens names a repair program.
297 *
298 * Two limits are intentional. A final-component symlink at the baseline
299 * `doctor.toml` closes the Edit/Write hatch because resolved placement refuses
300 * links; the Bash repair lane remains available. A malformed
301 * `schemas/sources.toml` is an enforcing state, but that file is not a permitted
302 * edit target: its in-session exit is the `doctor.toml` off-switch.
303 *
304 * Scanning tokens rather than anchoring at the start is the load-bearing
305 * choice. `^\s*(claude|mise)` looks stricter and is mostly a trap: it refuses
306 * `cd /repo && claude install latest`, `FOO=1 mise run doctor`, and every
307 * absolute-path invocation - the shapes a real repair actually takes.
308 *
309 * The cost, stated plainly: a command that merely MENTIONS a repair program
310 * (`echo claude`, `rm -rf x # claude`) also passes. That is accepted. This is a
311 * guard against a broken install, not against a user working around their own
312 * gate - and the failure it prevents is silent wrong-version execution, not
313 * malice.
314 */
315async function isRepairPermitted(
316  $: HookServices,
317  e: { tool: string } & Record<string, unknown>,
318): Promise<boolean> {
319  if (READ_ONLY_TOOLS.has(e.tool) || ESCAPE_HATCH_TOOLS.has(e.tool)) {
320    return true;
321  }
322  if (await isDoctorTomlRepair($, e)) {
323    return true;
324  }
325  if (e.tool !== "Bash") {
326    return false;
327  }
328  const command = typeof e.command === "string" ? e.command : "";
329  return command
330    .split(COMMAND_SEPARATORS)
331    .some((token) => {
332      if (token.includes(NATIVE_VERSIONS_DIR)) {
333        return true;
334      }
335      // Drop a `VAR=value` prefix and any directory part, so `/usr/bin/mise`
336      // and `MISE_ENV=x mise` both reduce to the program name.
337      const program = token.split("=").pop() ?? "";
338      return REPAIR_PROGRAMS.has(program.slice(program.lastIndexOf("/") + 1));
339    });
340}
341
342export const register: Register = (on) => {
343  on("classic.SessionStart", async ($, e, next) => {
344    const result = await next(e);
345    cachedReport = await readVerdict($);
346    lastRefreshAtMs = null;
347
348    if (
349      cachedReport === null ||
350      (cachedReport.enforcement_eligibility_malformed && cachedReport.verdict !== "invalid")
351    ) {
352      return {
353        ...result,
354        additionalContext: [
355          "install-doctor: the check could not run. This is not a clean bill of health - it is an unanswered question.",
356        ],
357      };
358    }
359    if (cachedReport.verdict === "ok") {
360      // A healthy verdict can still carry a diagnostic - an unreadable
361      // doctor.toml yields exactly this on a native host. Render-only: the
362      // verdict stays "ok" and nothing here can enforce.
363      if (cachedReport.findings.length === 0) {
364        return result;
365      }
366      return {
367        ...result,
368        additionalContext: [
369          ["install-doctor: your install is current, with a note.", ...cachedReport.findings].join(" "),
370        ],
371      };
372    }
373    const lead =
374      cachedReport.verdict === "invalid"
375        ? "install-doctor: your Claude Code install failed a required check — repair it before continuing."
376        : cachedReport.verdict === "drift"
377          ? "install-doctor: the repository's Claude Code pin is stale."
378          : "install-doctor: could not determine whether your install is current (this is NOT 'it is fine').";
379    return {
380      ...result,
381      additionalContext: [[lead, ...cachedReport.findings].join(" ")],
382    };
383  });
384
385  on("classic.PreToolUse", async ($, e, next) => {
386    const result = await next(e);
387    // Python owns the judgement. UNKNOWN, DRIFT and a null cache pass through;
388    // malformed older JSON retains the prior INVALID fallback.
389    if (!isEnforcementEligible(cachedReport)) {
390      return result;
391    }
392    if (await isRepairPermitted($, e)) {
393      return result;
394    }
395
396    // Only the about-to-deny path refreshes. A null, malformed, or unanswered
397    // refresh never weakens the prior enforcing verdict. The timestamp is read
398    // rather than awaited as a delay: the hook clock stops during every `$` call
399    // except a `$.clock` wait (`claude-code.d.ts:4173-4175`).
400    try {
401      const now = await $.clock.now();
402      if (
403        lastRefreshAtMs === null ||
404        now < lastRefreshAtMs ||
405        now - lastRefreshAtMs >= REFRESH_REUSE_MS
406      ) {
407        lastRefreshAtMs = now;
408        await refreshCachedReport($);
409      }
410    } catch {
411      await refreshCachedReport($);
412    }
413    if (!isEnforcementEligible(cachedReport)) {
414      return result;
415    }
416    // NOT `{ ...result, deny }`. A deny is one arm of a discriminated union, so
417    // spreading a result that already decided `allow: true` yields
418    // `{ allow: true, deny: "..." }` - which `claude plugin validate` accepts
419    // and `tsc` rejects. The two build gates are complementary, and this is the
420    // class the typecheck exists to catch.
421    return {
422      deny: [
423        "install-doctor: refusing tool calls until the Claude Code install is repaired.",
424        ...(cachedReport?.findings ?? []),
425      ].join(" "),
426      additionalContext: result.additionalContext,
427    };
428  });
429};
430