From c84e8a03336f8d44301652aadc2b177a7f2502df Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 7 May 2026 08:18:23 -0500 Subject: feat(make): add doctor target for ~/.claude drift detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit =make doctor= scans =~/.claude/= and reports drift against the repo + settings.json. Read-only diagnostic. Eight checks cover skills, rules, default hooks, claude config, settings.json hook references, enabledPlugins, MCP server registrations, and dangling symlinks. Each line prints =ok= / =WARN= / =FAIL= with a final summary. Exit 1 on any FAIL. A sweep last night found =~/.claude/hooks/= didn't exist on this machine even though =settings.json= referenced a PreCompact hook there. Compaction would have silently failed to invoke it. doctor catches that kind of drift in one command instead of relying on a manual look. The MCP drift check reads =~/.claude.json= directly rather than parsing =claude mcp list=. The CLI has no JSON output and runs a per-server health probe (~10s). The JSON file is the user-scope source of truth for registrations and parses in well under a second. I verified by injecting four drift scenarios — removed hook symlink, removed skill symlink, moved-aside plugin data dir, unregistered MCP server. Each produced the expected =FAIL= line and exit 1. After restoring state, doctor came back clean (33 ok). Bundling four other improvement TODOs from the same sweep — =mcp/README.org=, =make uninstall-mcp= and =mcp/install.py --check=, a README.org section for the MCP install pipeline, and a token-rotation helper for =@a-bonus/google-docs-mcp= OAuth refresh. Plus a stale-bullet note on the existing =make remove= TODO (the bridge symlink it references was removed earlier). --- Makefile | 5 +- scripts/doctor.sh | 177 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ todo.org | 83 +++++++++++++++++++++++++ 3 files changed, 264 insertions(+), 1 deletion(-) create mode 100755 scripts/doctor.sh diff --git a/Makefile b/Makefile index e6f6f56..c924289 100644 --- a/Makefile +++ b/Makefile @@ -62,7 +62,7 @@ endef .PHONY: help install uninstall list install-hooks uninstall-hooks \ install-lang install-elisp install-python list-languages \ - install-mcp diff lint deps + install-mcp diff lint doctor deps ##@ General @@ -333,3 +333,6 @@ diff: ## Show drift between installed ruleset and repo source ([LANG=] [PR lint: ## Validate ruleset structure (headings, Applies-to, shebangs, exec bits) @bash scripts/lint.sh + +doctor: ## Verify ~/.claude/ live state matches repo + settings.json (drift detector) + @bash scripts/doctor.sh diff --git a/scripts/doctor.sh b/scripts/doctor.sh new file mode 100755 index 0000000..93a4d22 --- /dev/null +++ b/scripts/doctor.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# doctor.sh — verify ~/.claude/ live state matches rulesets repo + settings.json. +# +# Read-only diagnostic. Reports drift line-by-line: ok / WARN / FAIL. +# Exit 0 on clean, 1 if any FAIL was emitted. Warnings do not block. +# +# Run from the repo root via `make doctor`. + +set -uo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +CLAUDE_DIR="$HOME/.claude" +SETTINGS="$REPO/.claude/settings.json" + +ok_count=0 +warn_count=0 +fail_count=0 + +ok() { printf ' ok %s\n' "$1"; ok_count=$((ok_count+1)); } +warn() { printf ' WARN %s\n' "$1"; warn_count=$((warn_count+1)); } +fail() { printf ' FAIL %s\n' "$1"; fail_count=$((fail_count+1)); } + +require() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "ERROR: required tool '$1' not found in PATH" >&2 + exit 2 + fi +} + +is_live_symlink() { + [ -L "$1" ] && [ -e "$1" ] +} + +# Reports ok / dangling / missing for a single symlink. +# Args: